Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
)
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -56,32 +55,27 @@ public class EditTabPresenter(
override val initialText: UiState<String> = initialText
override val withAvatar: Boolean = withAvatar
override val availableIcons: ImmutableList<IconType> =
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) {
Expand All @@ -102,13 +96,15 @@ public class EditTabPresenter(
IconType.Mixed(value.icon, account.accountKey)

is IconType.Url -> value
is IconType.FavIcon -> value
}
} else {
when (value) {
is IconType.Avatar -> value
is IconType.Material -> value
is IconType.Mixed -> IconType.Material(value.icon)
is IconType.Url -> value
is IconType.FavIcon -> value
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Comment on lines +55 to +59
Copy link

Copilot AI Nov 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Inconsistent use of tryRun vs runCatching. The file Rss.kt was updated to use standard Kotlin runCatching, but this file still uses the custom tryRun function. For consistency across the codebase, consider using runCatching here as well, similar to the changes in Rss.kt.

Copilot uses AI. Check for mistakes.
val feed =
tryRun {
fetch(url)
xml.decodeFromString<Feed>(webContent)
}.getOrNull()
val feedIcon =
when (feed) {
is Feed.Atom -> feed.icon
is Feed.RDF -> null
is Feed.Rss20 -> null
else -> null
null -> null
Copy link

Copilot AI Nov 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The null -> null branch is redundant since this is already the default behavior of a when expression that exhausts all sealed interface members. Consider using else -> null instead, or remove the explicit null handling if the sealed interface is exhaustive.

Suggested change
null -> null

Copilot uses AI. Check for mistakes.
}
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"
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
@@ -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<UiState<String>>(),
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<String> = flow.collectAsState().toUi()
}
Loading