Skip to content

Commit 9893537

Browse files
authored
Merge pull request #1482 from DimensionDev/bugfix/fav_icon
add FavIconPresenter to handle favicon retrieval and caching
2 parents 8d5e906 + 3b6e5a1 commit 9893537

File tree

10 files changed

+213
-60
lines changed

10 files changed

+213
-60
lines changed

compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@ public sealed class IconType {
101101
val url: String,
102102
) : IconType()
103103

104+
@Serializable
105+
public data class FavIcon(
106+
val host: String,
107+
) : IconType()
108+
104109
@Serializable
105110
public data class Material(
106111
val icon: MaterialIcon,
@@ -669,13 +674,13 @@ public data class HomeTimelineTabItem(
669674
),
670675
)
671676

672-
public constructor(accountKey: MicroBlogKey, icon: String, title: String) :
677+
public constructor(accountKey: MicroBlogKey, title: String) :
673678
this(
674679
account = AccountType.Specific(accountKey),
675680
metaData =
676681
TabMetaData(
677682
title = TitleType.Text(title),
678-
icon = IconType.Url(icon),
683+
icon = IconType.FavIcon(accountKey.host),
679684
),
680685
)
681686
}

compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/TabIcon.kt

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import dev.dimension.flare.ui.component.platform.placeholder
6666
import dev.dimension.flare.ui.icons.Misskey
6767
import dev.dimension.flare.ui.model.onLoading
6868
import dev.dimension.flare.ui.model.onSuccess
69+
import dev.dimension.flare.ui.presenter.home.FavIconPresenter
6970
import dev.dimension.flare.ui.presenter.home.UserPresenter
7071
import dev.dimension.flare.ui.theme.PlatformContentColor
7172
import dev.dimension.flare.ui.theme.PlatformTheme
@@ -223,6 +224,37 @@ public fun TabIcon(
223224
contentScale = ContentScale.Fit,
224225
)
225226
}
227+
228+
is IconType.FavIcon -> {
229+
val iconState by producePresenter(key = "fav-$accountType:${icon.host}") {
230+
remember(accountType, icon) {
231+
FavIconPresenter(
232+
icon.host,
233+
)
234+
}.body()
235+
}
236+
iconState
237+
.onSuccess {
238+
NetworkImage(
239+
it,
240+
contentDescription =
241+
when (title) {
242+
is TitleType.Localized -> stringResource(title.res)
243+
is TitleType.Text -> title.content
244+
},
245+
modifier =
246+
modifier
247+
.size(size),
248+
contentScale = ContentScale.Fit,
249+
)
250+
}.onLoading {
251+
AvatarComponent(
252+
null,
253+
size = size,
254+
modifier = Modifier.placeholder(true),
255+
)
256+
}
257+
}
226258
}
227259
}
228260

compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import dev.dimension.flare.data.repository.SettingsRepository
1111
import dev.dimension.flare.model.AccountType
1212
import dev.dimension.flare.model.PlatformType
1313
import dev.dimension.flare.model.vvo
14-
import dev.dimension.flare.ui.model.UiRssSource
1514
import dev.dimension.flare.ui.model.UiState
1615
import dev.dimension.flare.ui.model.collectAsUiState
1716
import dev.dimension.flare.ui.presenter.home.UserPresenter
@@ -80,7 +79,6 @@ public class HomeTimelineWithTabsPresenter(
8079
val tab =
8180
HomeTimelineTabItem(
8281
accountKey = account.accountKey,
83-
icon = UiRssSource.favIconUrl(account.accountKey.host),
8482
title =
8583
when (account.platformType) {
8684
PlatformType.Mastodon -> "Mastodon"

compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/settings/EditTabPresenter.kt

Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import dev.dimension.flare.data.model.TabItem
1212
import dev.dimension.flare.data.model.TitleType
1313
import dev.dimension.flare.model.AccountType
1414
import dev.dimension.flare.ui.component.res
15-
import dev.dimension.flare.ui.model.UiRssSource
1615
import dev.dimension.flare.ui.model.UiState
1716
import dev.dimension.flare.ui.presenter.PresenterBase
1817
import kotlinx.collections.immutable.ImmutableList
@@ -56,32 +55,27 @@ public class EditTabPresenter(
5655
override val initialText: UiState<String> = initialText
5756
override val withAvatar: Boolean = withAvatar
5857
override val availableIcons: ImmutableList<IconType> =
59-
kotlin
60-
.run {
61-
when (val account = tabItem.account) {
62-
is AccountType.Specific ->
63-
listOf(
64-
IconType.Avatar(account.accountKey),
65-
IconType.Url(
66-
UiRssSource.favIconUrl(account.accountKey.host),
67-
),
68-
)
58+
run {
59+
when (val account = tabItem.account) {
60+
is AccountType.Specific ->
61+
listOf(
62+
IconType.Avatar(account.accountKey),
63+
IconType.FavIcon(account.accountKey.host),
64+
)
6965

70-
else -> emptyList()
66+
else -> emptyList()
67+
} +
68+
IconType.Material.MaterialIcon.entries.map {
69+
IconType.Material(it)
7170
} +
72-
IconType.Material.MaterialIcon.entries.map {
73-
IconType.Material(it)
74-
} +
75-
if (tabItem is RssTimelineTabItem) {
76-
listOfNotNull(
77-
tabItem.favIcon?.let { IconType.Url(it) },
78-
)
79-
} else {
80-
emptyList()
81-
}
82-
}.let {
83-
it.toPersistentList()
84-
}
71+
if (tabItem is RssTimelineTabItem) {
72+
listOfNotNull(
73+
tabItem.favIcon?.let { IconType.Url(it) },
74+
)
75+
} else {
76+
emptyList()
77+
}
78+
}.toPersistentList()
8579
override val icon = icon
8680

8781
override fun setWithAvatar(value: Boolean) {
@@ -102,13 +96,15 @@ public class EditTabPresenter(
10296
IconType.Mixed(value.icon, account.accountKey)
10397

10498
is IconType.Url -> value
99+
is IconType.FavIcon -> value
105100
}
106101
} else {
107102
when (value) {
108103
is IconType.Avatar -> value
109104
is IconType.Material -> value
110105
is IconType.Mixed -> IconType.Material(value.icon)
111106
is IconType.Url -> value
107+
is IconType.FavIcon -> value
112108
}
113109
}
114110
}

iosApp/flare/UI/Component/TabIcon.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ struct TabIcon: View {
7575
}
7676
.frame(width: size, height: size)
7777
}
78+
case .favIcon(let favIcon):
79+
FavTabIcon(host: favIcon.host)
80+
.frame(width: size, height: size)
7881
}
7982
}
8083
}
@@ -175,6 +178,30 @@ struct AvatarTabIcon: View {
175178
var body: some View {
176179
StateView(state: presenter.state.user) { user in
177180
AvatarView(data: user.avatar)
181+
} loadingContent: {
182+
Image("fa-globe")
183+
.resizable()
184+
.scaledToFit()
185+
.redacted(reason: .placeholder)
186+
}
187+
}
188+
}
189+
190+
struct FavTabIcon: View {
191+
@StateObject private var presenter: KotlinPresenter<UiState<NSString>>
192+
193+
init(host: String) {
194+
self._presenter = .init(wrappedValue: .init(presenter: FavIconPresenter(host: host)))
195+
}
196+
197+
var body: some View {
198+
StateView(state: presenter.state) { url in
199+
NetworkImage(data: .init(url))
200+
} loadingContent: {
201+
Image("fa-globe")
202+
.resizable()
203+
.scaledToFit()
204+
.redacted(reason: .placeholder)
178205
}
179206
}
180207
}

shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/EmojiContent.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import dev.dimension.flare.data.network.vvo.model.EmojiData
66
import kotlinx.serialization.SerialName
77
import kotlinx.serialization.Serializable
88

9-
// https://github.com/cashapp/sqldelight/issues/1333
109
@Serializable
1110
internal sealed interface EmojiContent {
1211
@Serializable
@@ -26,4 +25,10 @@ internal sealed interface EmojiContent {
2625
data class VVO internal constructor(
2726
internal val data: EmojiData,
2827
) : EmojiContent
28+
29+
@Serializable
30+
@SerialName("FavIcon")
31+
data class FavIcon internal constructor(
32+
internal val data: String,
33+
) : EmojiContent
2934
}

shared/src/commonMain/kotlin/dev/dimension/flare/data/network/rss/RssService.kt

Lines changed: 58 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import io.ktor.client.statement.bodyAsText
1212
import io.ktor.http.ContentType
1313
import io.ktor.http.Url
1414
import io.ktor.serialization.kotlinx.xml.xml
15+
import kotlinx.serialization.decodeFromString
1516
import nl.adaptivity.xmlutil.serialization.XML
1617

1718
internal object RssService {
@@ -50,55 +51,86 @@ internal object RssService {
5051
?: throw IllegalArgumentException("No RSS or Atom feeds found at the provided URL: $url")
5152

5253
suspend fun fetchIcon(url: String): String? {
54+
val webContent =
55+
tryRun {
56+
ktorClient(
57+
config = {},
58+
).get(url).bodyAsText()
59+
}.getOrNull() ?: return null
5360
val feed =
5461
tryRun {
55-
fetch(url)
62+
xml.decodeFromString<Feed>(webContent)
5663
}.getOrNull()
5764
val feedIcon =
5865
when (feed) {
5966
is Feed.Atom -> feed.icon
6067
is Feed.RDF -> null
6168
is Feed.Rss20 -> null
62-
else -> null
69+
null -> null
6370
}
6471
if (feedIcon != null) {
6572
return feedIcon
6673
}
6774
val feedLink = feed?.link ?: url
6875
val parsedUrl = Url(feedLink)
69-
val favIcon = "https://${parsedUrl.host}/favicon.ico"
70-
val hasFavIcon =
71-
tryRun {
72-
val response = ktorClient().get(favIcon)
73-
if (response.status.value !in 200..299) {
74-
throw Exception("Failed to fetch favicon: ${response.status}")
75-
}
76-
}
77-
if (hasFavIcon.isSuccess) {
78-
return favIcon
79-
}
8076
val html =
81-
tryRun {
82-
ktorClient().get(feedLink).bodyAsText()
83-
}.getOrNull() ?: return null
77+
if (feed?.link != null && feed.link != url) {
78+
tryRun {
79+
ktorClient(
80+
config = {},
81+
).get(feedLink).bodyAsText()
82+
}.getOrNull() ?: return null
83+
} else {
84+
webContent
85+
}
8486
val document = Ksoup.parse(html)
85-
val iconLink =
87+
val icons =
8688
document
8789
.select(
8890
"""
8991
link[rel~=(?i)(?:^|\s)(?:icon|apple-touch-icon(?:-precomposed)?|mask-icon)(?:\s|$)],
9092
link[rel~=(?i)^(?=.*\bshortcut\b)(?=.*\bicon\b).*$]
91-
9293
""".trimIndent(),
93-
).firstOrNull()
94-
?: return null
95-
val iconHref = iconLink.attr("href").ifBlank { return null }
96-
return if (iconHref.startsWith("http")) {
97-
iconHref
98-
} else if (iconHref.startsWith("/")) {
99-
"https://${parsedUrl.host}$iconHref"
94+
)
95+
val iconLink =
96+
icons.maxByOrNull {
97+
it
98+
.attribute("sizes")
99+
?.value
100+
?.split('x')
101+
?.firstOrNull()
102+
?.toIntOrNull() ?: 0
103+
}
104+
if (iconLink == null) {
105+
val favIcon = "https://${parsedUrl.host}/favicon.ico"
106+
val hasFavIcon =
107+
tryRun {
108+
val response =
109+
ktorClient(
110+
config = {},
111+
).get(favIcon)
112+
if (response.status.value !in 200..299) {
113+
throw Exception("Failed to fetch favicon: ${response.status}")
114+
}
115+
}
116+
if (hasFavIcon.isSuccess) {
117+
return favIcon
118+
} else {
119+
return null
120+
}
100121
} else {
101-
"https://${parsedUrl.host}/$iconHref"
122+
val iconHref = iconLink.attr("href").ifBlank { return null }
123+
return if (iconHref.startsWith("http")) {
124+
iconHref
125+
} else if (iconHref.startsWith("/")) {
126+
if (iconHref.startsWith("//")) {
127+
"https:$iconHref"
128+
} else {
129+
"https://${parsedUrl.host}$iconHref"
130+
}
131+
} else {
132+
"https://${parsedUrl.host}/$iconHref"
133+
}
102134
}
103135
}
104136
}

shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Mastodon.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,6 +809,8 @@ internal fun DbEmoji.toUi(): List<UiEmoji> =
809809
)
810810
}
811811
}.orEmpty()
812+
813+
is EmojiContent.FavIcon -> emptyList()
812814
}
813815

814816
private fun parseName(status: Account): Element {

shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Rss.kt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package dev.dimension.flare.ui.model.mapper
33
import com.fleeksoft.ksoup.Ksoup
44
import dev.dimension.flare.data.database.cache.model.StatusContent
55
import dev.dimension.flare.data.network.rss.model.Feed
6-
import dev.dimension.flare.data.repository.tryRun
76
import dev.dimension.flare.model.MicroBlogKey
87
import dev.dimension.flare.ui.model.UiTimeline
98
import dev.dimension.flare.ui.render.toUi
@@ -136,11 +135,11 @@ internal fun MicroBlogKey.Companion.fromRss(url: String) =
136135
)
137136

138137
private fun parseRssDateToInstant(input: String): Instant? =
139-
tryRun {
138+
runCatching {
140139
Instant.parse(input)
141-
}.getOrNull() ?: tryRun {
140+
}.getOrNull() ?: runCatching {
142141
parseRfc2822LikeToInstant(input)
143-
}.getOrNull() ?: tryRun {
142+
}.getOrNull() ?: runCatching {
144143
parseRfc1123ToInstant(input)
145144
}.getOrNull()
146145

0 commit comments

Comments
 (0)