From 6f6bf826997ed4973fa1d41df1b18703cb9009d2 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Fri, 7 Nov 2025 17:30:39 +0900 Subject: [PATCH 1/3] add FavIconPresenter to handle favicon retrieval and caching --- .../dimension/flare/data/model/TabSettings.kt | 9 +- .../dimension/flare/ui/component/TabIcon.kt | 32 +++++++ .../HomeTimelineWithTabsPresenter.kt | 2 - .../ui/screen/settings/EditTabPresenter.kt | 46 +++++----- .../data/database/cache/model/EmojiContent.kt | 7 +- .../flare/data/network/rss/RssService.kt | 84 +++++++++++++------ .../dimension/flare/ui/model/mapper/Rss.kt | 7 +- .../ui/presenter/home/FavIconPresenter.kt | 57 +++++++++++++ 8 files changed, 184 insertions(+), 60 deletions(-) create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/FavIconPresenter.kt diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt index ee00d6db5..c862aedb7 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt @@ -101,6 +101,11 @@ public sealed class IconType { val url: String, ) : IconType() + @Serializable + public data class FavIcon( + val host: String, + ) : IconType() + @Serializable public data class Material( val icon: MaterialIcon, @@ -669,13 +674,13 @@ public data class HomeTimelineTabItem( ), ) - public constructor(accountKey: MicroBlogKey, icon: String, title: String) : + public constructor(accountKey: MicroBlogKey, title: String) : this( account = AccountType.Specific(accountKey), metaData = TabMetaData( title = TitleType.Text(title), - icon = IconType.Url(icon), + icon = IconType.FavIcon(accountKey.host), ), ) } diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/TabIcon.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/TabIcon.kt index 2280b4bf5..0a9432904 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/TabIcon.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/TabIcon.kt @@ -66,6 +66,7 @@ import dev.dimension.flare.ui.component.platform.placeholder import dev.dimension.flare.ui.icons.Misskey import dev.dimension.flare.ui.model.onLoading import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.presenter.home.FavIconPresenter import dev.dimension.flare.ui.presenter.home.UserPresenter import dev.dimension.flare.ui.theme.PlatformContentColor import dev.dimension.flare.ui.theme.PlatformTheme @@ -223,6 +224,37 @@ public fun TabIcon( contentScale = ContentScale.Fit, ) } + + is IconType.FavIcon -> { + val iconState by producePresenter(key = "fav-$accountType:${icon.host}") { + remember(accountType, icon) { + FavIconPresenter( + icon.host, + ) + }.body() + } + iconState + .onSuccess { + NetworkImage( + it, + contentDescription = + when (title) { + is TitleType.Localized -> stringResource(title.res) + is TitleType.Text -> title.content + }, + modifier = + modifier + .size(size), + contentScale = ContentScale.Fit, + ) + }.onLoading { + AvatarComponent( + null, + size = size, + modifier = Modifier.placeholder(true), + ) + } + } } } diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt index ac9fcd904..d96387d01 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt @@ -11,7 +11,6 @@ import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.PlatformType import dev.dimension.flare.model.vvo -import dev.dimension.flare.ui.model.UiRssSource import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.model.collectAsUiState import dev.dimension.flare.ui.presenter.home.UserPresenter @@ -80,7 +79,6 @@ public class HomeTimelineWithTabsPresenter( val tab = HomeTimelineTabItem( accountKey = account.accountKey, - icon = UiRssSource.favIconUrl(account.accountKey.host), title = when (account.platformType) { PlatformType.Mastodon -> "Mastodon" diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/settings/EditTabPresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/settings/EditTabPresenter.kt index 58f8623a8..68711bf35 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/settings/EditTabPresenter.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/settings/EditTabPresenter.kt @@ -12,7 +12,6 @@ import dev.dimension.flare.data.model.TabItem import dev.dimension.flare.data.model.TitleType import dev.dimension.flare.model.AccountType import dev.dimension.flare.ui.component.res -import dev.dimension.flare.ui.model.UiRssSource import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.presenter.PresenterBase import kotlinx.collections.immutable.ImmutableList @@ -56,32 +55,27 @@ public class EditTabPresenter( override val initialText: UiState = initialText override val withAvatar: Boolean = withAvatar override val availableIcons: ImmutableList = - kotlin - .run { - when (val account = tabItem.account) { - is AccountType.Specific -> - listOf( - IconType.Avatar(account.accountKey), - IconType.Url( - UiRssSource.favIconUrl(account.accountKey.host), - ), - ) + run { + when (val account = tabItem.account) { + is AccountType.Specific -> + listOf( + IconType.Avatar(account.accountKey), + IconType.FavIcon(account.accountKey.host), + ) - else -> emptyList() + else -> emptyList() + } + + IconType.Material.MaterialIcon.entries.map { + IconType.Material(it) } + - IconType.Material.MaterialIcon.entries.map { - IconType.Material(it) - } + - if (tabItem is RssTimelineTabItem) { - listOfNotNull( - tabItem.favIcon?.let { IconType.Url(it) }, - ) - } else { - emptyList() - } - }.let { - it.toPersistentList() - } + if (tabItem is RssTimelineTabItem) { + listOfNotNull( + tabItem.favIcon?.let { IconType.Url(it) }, + ) + } else { + emptyList() + } + }.toPersistentList() override val icon = icon override fun setWithAvatar(value: Boolean) { @@ -102,6 +96,7 @@ public class EditTabPresenter( IconType.Mixed(value.icon, account.accountKey) is IconType.Url -> value + is IconType.FavIcon -> value } } else { when (value) { @@ -109,6 +104,7 @@ public class EditTabPresenter( is IconType.Material -> value is IconType.Mixed -> IconType.Material(value.icon) is IconType.Url -> value + is IconType.FavIcon -> value } } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/EmojiContent.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/EmojiContent.kt index 61510c315..2a416d6fd 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/EmojiContent.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/EmojiContent.kt @@ -6,7 +6,6 @@ import dev.dimension.flare.data.network.vvo.model.EmojiData import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -// https://github.com/cashapp/sqldelight/issues/1333 @Serializable internal sealed interface EmojiContent { @Serializable @@ -26,4 +25,10 @@ internal sealed interface EmojiContent { data class VVO internal constructor( internal val data: EmojiData, ) : EmojiContent + + @Serializable + @SerialName("FavIcon") + data class FavIcon internal constructor( + internal val data: String, + ) : EmojiContent } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/rss/RssService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/rss/RssService.kt index 255f57667..f559438d7 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/rss/RssService.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/rss/RssService.kt @@ -12,6 +12,7 @@ import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.Url import io.ktor.serialization.kotlinx.xml.xml +import kotlinx.serialization.decodeFromString import nl.adaptivity.xmlutil.serialization.XML internal object RssService { @@ -50,55 +51,86 @@ internal object RssService { ?: throw IllegalArgumentException("No RSS or Atom feeds found at the provided URL: $url") suspend fun fetchIcon(url: String): String? { + val webContent = + tryRun { + ktorClient( + config = {}, + ).get(url).bodyAsText() + }.getOrNull() ?: return null val feed = tryRun { - fetch(url) + xml.decodeFromString(webContent) }.getOrNull() val feedIcon = when (feed) { is Feed.Atom -> feed.icon is Feed.RDF -> null is Feed.Rss20 -> null - else -> null + null -> null } if (feedIcon != null) { return feedIcon } val feedLink = feed?.link ?: url val parsedUrl = Url(feedLink) - val favIcon = "https://${parsedUrl.host}/favicon.ico" - val hasFavIcon = - tryRun { - val response = ktorClient().get(favIcon) - if (response.status.value !in 200..299) { - throw Exception("Failed to fetch favicon: ${response.status}") - } - } - if (hasFavIcon.isSuccess) { - return favIcon - } val html = - tryRun { - ktorClient().get(feedLink).bodyAsText() - }.getOrNull() ?: return null + if (feed?.link != null && feed.link != url) { + tryRun { + ktorClient( + config = {}, + ).get(feedLink).bodyAsText() + }.getOrNull() ?: return null + } else { + webContent + } val document = Ksoup.parse(html) - val iconLink = + val icons = document .select( """ link[rel~=(?i)(?:^|\s)(?:icon|apple-touch-icon(?:-precomposed)?|mask-icon)(?:\s|$)], link[rel~=(?i)^(?=.*\bshortcut\b)(?=.*\bicon\b).*$] - """.trimIndent(), - ).firstOrNull() - ?: return null - val iconHref = iconLink.attr("href").ifBlank { return null } - return if (iconHref.startsWith("http")) { - iconHref - } else if (iconHref.startsWith("/")) { - "https://${parsedUrl.host}$iconHref" + ) + val iconLink = + icons.maxByOrNull { + it + .attribute("sizes") + ?.value + ?.split('x') + ?.firstOrNull() + ?.toIntOrNull() ?: 0 + } + if (iconLink == null) { + val favIcon = "https://${parsedUrl.host}/favicon.ico" + val hasFavIcon = + tryRun { + val response = + ktorClient( + config = {}, + ).get(favIcon) + if (response.status.value !in 200..299) { + throw Exception("Failed to fetch favicon: ${response.status}") + } + } + if (hasFavIcon.isSuccess) { + return favIcon + } else { + return null + } } else { - "https://${parsedUrl.host}/$iconHref" + val iconHref = iconLink.attr("href").ifBlank { return null } + return if (iconHref.startsWith("http")) { + iconHref + } else if (iconHref.startsWith("/")) { + if (iconHref.startsWith("//")) { + "https:$iconHref" + } else { + "https://${parsedUrl.host}$iconHref" + } + } else { + "https://${parsedUrl.host}/$iconHref" + } } } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Rss.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Rss.kt index 00590a434..f089304bd 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Rss.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Rss.kt @@ -3,7 +3,6 @@ package dev.dimension.flare.ui.model.mapper import com.fleeksoft.ksoup.Ksoup import dev.dimension.flare.data.database.cache.model.StatusContent import dev.dimension.flare.data.network.rss.model.Feed -import dev.dimension.flare.data.repository.tryRun import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiTimeline import dev.dimension.flare.ui.render.toUi @@ -136,11 +135,11 @@ internal fun MicroBlogKey.Companion.fromRss(url: String) = ) private fun parseRssDateToInstant(input: String): Instant? = - tryRun { + runCatching { Instant.parse(input) - }.getOrNull() ?: tryRun { + }.getOrNull() ?: runCatching { parseRfc2822LikeToInstant(input) - }.getOrNull() ?: tryRun { + }.getOrNull() ?: runCatching { parseRfc1123ToInstant(input) }.getOrNull() diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/FavIconPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/FavIconPresenter.kt new file mode 100644 index 000000000..5ba57fb84 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/FavIconPresenter.kt @@ -0,0 +1,57 @@ +package dev.dimension.flare.ui.presenter.home + +import androidx.compose.runtime.Composable +import dev.dimension.flare.common.Cacheable +import dev.dimension.flare.common.collectAsState +import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.model.DbEmoji +import dev.dimension.flare.data.database.cache.model.EmojiContent +import dev.dimension.flare.data.network.rss.RssService +import dev.dimension.flare.ui.model.UiState +import dev.dimension.flare.ui.model.toUi +import dev.dimension.flare.ui.presenter.PresenterBase +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +public class FavIconPresenter( + private val host: String, +) : PresenterBase>(), + KoinComponent { + private val dbKey by lazy { + "$host-favIcon" + } + + private val database: CacheDatabase by inject() + private val flow by lazy { + Cacheable( + fetchSource = { + val favIcon = RssService.fetchIcon("https://$host") + if (favIcon == null) { + throw IllegalStateException("Favicon not found") + } else { + database.emojiDao().insert( + DbEmoji( + host = dbKey, + content = EmojiContent.FavIcon(favIcon), + ), + ) + } + }, + cacheSource = { + database + .emojiDao() + .get(dbKey) + .mapNotNull { + it?.content as? EmojiContent.FavIcon + }.map { + it.data + } + }, + ) + } + + @Composable + override fun body(): UiState = flow.collectAsState().toUi() +} From af72744cdf184fa2e6f85764b559d40039831179 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Fri, 7 Nov 2025 18:10:39 +0900 Subject: [PATCH 2/3] fix build --- .../kotlin/dev/dimension/flare/ui/model/mapper/Mastodon.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Mastodon.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Mastodon.kt index 8bdd4df98..767e80901 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Mastodon.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Mastodon.kt @@ -717,6 +717,8 @@ internal fun DbEmoji.toUi(): List = ) } }.orEmpty() + + is EmojiContent.FavIcon -> emptyList() } private fun parseName(status: Account): Element { From 3b6e5a1bb0307ab5c19f9a615cd78f1da5705196 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sat, 8 Nov 2025 16:01:50 +0900 Subject: [PATCH 3/3] add fav icon for ios --- iosApp/flare/UI/Component/TabIcon.swift | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/iosApp/flare/UI/Component/TabIcon.swift b/iosApp/flare/UI/Component/TabIcon.swift index 5194c7caf..19f43ffd5 100644 --- a/iosApp/flare/UI/Component/TabIcon.swift +++ b/iosApp/flare/UI/Component/TabIcon.swift @@ -75,6 +75,9 @@ struct TabIcon: View { } .frame(width: size, height: size) } + case .favIcon(let favIcon): + FavTabIcon(host: favIcon.host) + .frame(width: size, height: size) } } } @@ -175,6 +178,30 @@ struct AvatarTabIcon: View { var body: some View { StateView(state: presenter.state.user) { user in AvatarView(data: user.avatar) + } loadingContent: { + Image("fa-globe") + .resizable() + .scaledToFit() + .redacted(reason: .placeholder) + } + } +} + +struct FavTabIcon: View { + @StateObject private var presenter: KotlinPresenter> + + init(host: String) { + self._presenter = .init(wrappedValue: .init(presenter: FavIconPresenter(host: host))) + } + + var body: some View { + StateView(state: presenter.state) { url in + NetworkImage(data: .init(url)) + } loadingContent: { + Image("fa-globe") + .resizable() + .scaledToFit() + .redacted(reason: .placeholder) } } }