From 2fa723bd8d61a1702f8e96961369b8127594384e Mon Sep 17 00:00:00 2001 From: Tlaster Date: Fri, 20 Mar 2026 20:23:47 +0900 Subject: [PATCH 1/6] [WIP] add nostr support --- .../composeResources/values/strings.xml | 6 + .../component/status/CommonStatusComponent.kt | 23 +- .../dimension/flare/ui/icons/MisskeyIcon.kt | 163 +++++- .../flare/ui/model/PlatformTypeIcon.kt | 23 + .../HomeTimelineWithTabsPresenter.kt | 12 +- .../ui/screen/login/NostrInputPresenter.kt | 46 ++ .../ui/screen/login/SelectionPresenter.kt | 3 + .../login/ServiceSelectionScreenContent.kt | 154 ++++- gradle/libs.versions.toml | 8 +- shared/build.gradle.kts | 1 + .../flare/common/deeplink/DeepLinkMapping.kt | 2 + .../data/datasource/nostr/NostrDataSource.kt | 211 +++++++ .../data/datasource/nostr/NostrLoader.kt | 52 ++ .../flare/data/network/nostr/NostrService.kt | 550 ++++++++++++++++++ .../dev/dimension/flare/model/PlatformType.kt | 68 ++- .../dev/dimension/flare/ui/model/UiAccount.kt | 33 ++ .../dimension/flare/ui/model/UiApplication.kt | 10 + .../login/InstanceMetadataPresenter.kt | 3 + .../ui/presenter/login/NostrLoginPresenter.kt | 106 ++++ .../presenter/login/ServiceSelectPresenter.kt | 6 +- .../data/network/nostr/NostrServiceTest.kt | 76 +++ .../nostr/NostrServiceJvmIntegrationTest.kt | 146 +++++ 22 files changed, 1637 insertions(+), 65 deletions(-) create mode 100644 compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model/PlatformTypeIcon.kt create mode 100644 compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/NostrInputPresenter.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrLoader.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/NostrLoginPresenter.kt create mode 100644 shared/src/commonTest/kotlin/dev/dimension/flare/data/network/nostr/NostrServiceTest.kt create mode 100644 shared/src/jvmTest/kotlin/dev/dimension/flare/data/network/nostr/NostrServiceJvmIntegrationTest.kt diff --git a/compose-ui/src/commonMain/composeResources/values/strings.xml b/compose-ui/src/commonMain/composeResources/values/strings.xml index 043c2c0f5..3bc27441d 100644 --- a/compose-ui/src/commonMain/composeResources/values/strings.xml +++ b/compose-ui/src/commonMain/composeResources/values/strings.xml @@ -454,6 +454,12 @@ Bluesky OAuth might not stable enough, please use password login if you encounter any issues. Login Please wait while we verify your credentials. + Import Nostr account + Paste an npub or hex pubkey for read-only access, or provide an nsec to import a writable account. + Generate and login + npub or hex pubkey (optional if nsec is set) + nsec or hex private key + Relay URLs, separated by commas Instance URL Next No instances found diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt index 9a82da8e6..3418bcf90 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt @@ -46,13 +46,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach import compose.icons.FontAwesomeIcons -import compose.icons.fontawesomeicons.Brands import compose.icons.fontawesomeicons.Regular import compose.icons.fontawesomeicons.Solid -import compose.icons.fontawesomeicons.brands.Bluesky -import compose.icons.fontawesomeicons.brands.Mastodon -import compose.icons.fontawesomeicons.brands.Weibo -import compose.icons.fontawesomeicons.brands.XTwitter import compose.icons.fontawesomeicons.regular.Bookmark import compose.icons.fontawesomeicons.regular.CommentDots import compose.icons.fontawesomeicons.regular.Heart @@ -125,7 +120,6 @@ import dev.dimension.flare.compose.ui.user_unmute import dev.dimension.flare.compose.ui.vote import dev.dimension.flare.data.datasource.microblog.ActionMenu import dev.dimension.flare.data.model.PostActionStyle -import dev.dimension.flare.model.PlatformType import dev.dimension.flare.ui.component.AdaptiveGrid import dev.dimension.flare.ui.component.AvatarComponent import dev.dimension.flare.ui.component.DateTimeText @@ -146,13 +140,13 @@ import dev.dimension.flare.ui.component.platform.PlatformRadioButton import dev.dimension.flare.ui.component.platform.PlatformText import dev.dimension.flare.ui.component.platform.PlatformTextButton import dev.dimension.flare.ui.component.platform.PlatformTextStyle -import dev.dimension.flare.ui.icons.Misskey import dev.dimension.flare.ui.model.ClickContext import dev.dimension.flare.ui.model.UiCard import dev.dimension.flare.ui.model.UiIcon import dev.dimension.flare.ui.model.UiMedia import dev.dimension.flare.ui.model.UiPoll import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.brandIcon import dev.dimension.flare.ui.model.onError import dev.dimension.flare.ui.model.onLoading import dev.dimension.flare.ui.model.onSuccess @@ -235,21 +229,8 @@ public fun CommonStatusComponent( ) } if (appearanceSettings.showPlatformLogo) { - val icon = - when (item.platformType) { - PlatformType.Mastodon -> - FontAwesomeIcons.Brands.Mastodon - PlatformType.Misskey -> - FontAwesomeIcons.Brands.Misskey - PlatformType.Bluesky -> - FontAwesomeIcons.Brands.Bluesky - PlatformType.xQt -> - FontAwesomeIcons.Brands.XTwitter - PlatformType.VVo -> - FontAwesomeIcons.Brands.Weibo - } FAIcon( - imageVector = icon, + imageVector = item.platformType.brandIcon, contentDescription = null, modifier = Modifier diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/icons/MisskeyIcon.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/icons/MisskeyIcon.kt index 3103db442..4e24d038e 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/icons/MisskeyIcon.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/icons/MisskeyIcon.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes import androidx.compose.ui.graphics.vector.group import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp @@ -44,43 +45,127 @@ public val BrandsGroup.Misskey: ImageVector ) { moveTo(54.63f, 120.96f) curveToRelative(-1.97790f, 00f, -3.86180f, 0.32980f, -5.65130f, 0.98910f) - curveToRelative(-3.20230f, 1.13020f, -5.83960f, 3.15530f, -7.91170f, 6.07510f) - curveToRelative(-1.97790f, 2.82560f, -2.96670f, 5.98070f, -2.96670f, 9.46560f) + curveToRelative( + -3.20230f, + 1.13020f, + -5.83960f, + 3.15530f, + -7.91170f, + 6.07510f, + ) + curveToRelative( + -1.97790f, + 2.82560f, + -2.96670f, + 5.98070f, + -2.96670f, + 9.46560f, + ) verticalLineToRelative(61.88f) curveToRelative(00f, 4.52090f, 1.6010f, 8.42940f, 4.80330f, 11.7260f) curveToRelative(3.29650f, 3.20230f, 7.20550f, 4.80380f, 11.7260f, 4.80380f) curveToRelative(4.61510f, 00f, 8.52360f, -1.60150f, 11.7260f, -4.80380f) - curveToRelative(3.29650f, -3.29650f, 4.94490f, -7.2050f, 4.94490f, -11.7260f) + curveToRelative( + 3.29650f, + -3.29650f, + 4.94490f, + -7.2050f, + 4.94490f, + -11.7260f, + ) verticalLineToRelative(-11.253f) curveToRelative(0.03560f, -2.43710f, 2.5460f, -1.79770f, 3.81480f, 00f) curveToRelative(2.37630f, 4.11530f, 7.41420f, 7.64970f, 13.280f, 7.62950f) - curveToRelative(5.86560f, -0.02020f, 10.7370f, -2.92020f, 13.280f, -7.62950f) + curveToRelative( + 5.86560f, + -0.02020f, + 10.7370f, + -2.92020f, + 13.280f, + -7.62950f, + ) curveToRelative(0.9630f, -1.13580f, 3.67740f, -3.0710f, 3.95580f, 00f) verticalLineToRelative(11.253f) curveToRelative(00f, 4.52090f, 1.6010f, 8.42940f, 4.80330f, 11.7260f) curveToRelative(3.29650f, 3.20230f, 7.20550f, 4.80380f, 11.7260f, 4.80380f) curveToRelative(4.61510f, 00f, 8.52360f, -1.60150f, 11.7260f, -4.80380f) - curveToRelative(3.29650f, -3.29650f, 4.94490f, -7.2050f, 4.94490f, -11.7260f) + curveToRelative( + 3.29650f, + -3.29650f, + 4.94490f, + -7.2050f, + 4.94490f, + -11.7260f, + ) verticalLineToRelative(-61.88f) curveToRelative(00f, -3.48490f, -1.03570f, -6.640f, -3.10780f, -9.46560f) - curveToRelative(-1.97790f, -2.91980f, -4.56830f, -4.94480f, -7.77060f, -6.07510f) - curveToRelative(-1.88370f, -0.65930f, -3.76760f, -0.98910f, -5.65130f, -0.98910f) + curveToRelative( + -1.97790f, + -2.91980f, + -4.56830f, + -4.94480f, + -7.77060f, + -6.07510f, + ) + curveToRelative( + -1.88370f, + -0.65930f, + -3.76760f, + -0.98910f, + -5.65130f, + -0.98910f, + ) curveToRelative(-5.0860f, 00f, -9.37120f, 1.97820f, -12.8560f, 5.9340f) lineToRelative(-16.775f, 19.632f) - curveToRelative(-0.37670f, 0.28260f, -1.62480f, 2.44280f, -4.27570f, 2.44280f) + curveToRelative( + -0.37670f, + 0.28260f, + -1.62480f, + 2.44280f, + -4.27570f, + 2.44280f, + ) curveToRelative(-2.65090f, 00f, -3.75740f, -2.16020f, -4.13410f, -2.44280f) lineToRelative(-16.916f, -19.632f) - curveToRelative(-3.39070f, -3.95580f, -7.62890f, -5.9340f, -12.7150f, -5.9340f) + curveToRelative( + -3.39070f, + -3.95580f, + -7.62890f, + -5.9340f, + -12.7150f, + -5.9340f, + ) close() moveToRelative(104.53f, 0f) curveToRelative(-3.95580f, 00f, -7.34640f, 1.41290f, -10.1720f, 4.23850f) - curveToRelative(-2.73140f, 2.73140f, -4.09690f, 6.07510f, -4.09690f, 10.0310f) + curveToRelative( + -2.73140f, + 2.73140f, + -4.09690f, + 6.07510f, + -4.09690f, + 10.0310f, + ) curveToRelative(00f, 3.95580f, 1.36550f, 7.34640f, 4.09690f, 10.1720f) curveToRelative(2.82560f, 2.73140f, 6.21620f, 4.09740f, 10.1720f, 4.09740f) curveToRelative(3.95580f, 00f, 7.34640f, -1.3660f, 10.1720f, -4.09740f) - curveToRelative(2.82560f, -2.82560f, 4.23850f, -6.21620f, 4.23850f, -10.1720f) + curveToRelative( + 2.82560f, + -2.82560f, + 4.23850f, + -6.21620f, + 4.23850f, + -10.1720f, + ) curveToRelative(00f, -3.95580f, -1.41290f, -7.29950f, -4.23850f, -10.0310f) - curveToRelative(-2.82560f, -2.82560f, -6.21620f, -4.23850f, -10.1720f, -4.23850f) + curveToRelative( + -2.82560f, + -2.82560f, + -6.21620f, + -4.23850f, + -10.1720f, + -4.23850f, + ) close() moveToRelative(0.14107f, 31.364f) curveToRelative(-3.95580f, 00f, -7.34640f, 1.41290f, -10.1720f, 4.23850f) @@ -89,11 +174,61 @@ public val BrandsGroup.Misskey: ImageVector curveToRelative(00f, 3.95580f, 1.41240f, 7.34640f, 4.2380f, 10.1720f) curveToRelative(2.82560f, 2.73140f, 6.21620f, 4.09740f, 10.1720f, 4.09740f) reflectiveCurveToRelative(7.2995f, -1.366f, 10.031f, -4.0974f) - curveToRelative(2.82560f, -2.82560f, 4.23850f, -6.21620f, 4.23850f, -10.1720f) + curveToRelative( + 2.82560f, + -2.82560f, + 4.23850f, + -6.21620f, + 4.23850f, + -10.1720f, + ) verticalLineToRelative(-34.896f) curveToRelative(00f, -3.95580f, -1.41290f, -7.34640f, -4.23850f, -10.1720f) - curveToRelative(-2.73140f, -2.82560f, -6.07510f, -4.23850f, -10.0310f, -4.23850f) + curveToRelative( + -2.73140f, + -2.82560f, + -6.07510f, + -4.23850f, + -10.0310f, + -4.23850f, + ) close() } } }.build() + +@HiddenFromObjC +public val BrandsGroup.Nostr: ImageVector + get() { + return ImageVector + .Builder( + name = "NostrIcon", + defaultWidth = 256.dp, + defaultHeight = 256.dp, + viewportWidth = 256f, + viewportHeight = 256f, + ).apply { + path( + fill = SolidColor(Color(0xFF000000)), + stroke = null, + strokeLineWidth = 0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 4f, + pathFillType = PathFillType.NonZero, + pathBuilder = { + addPathNodes( + "M210.8 199.4c0 3.1-2.5 5.7-5.7 5.7h-68c-3.1 0-5.7-2.5-5.7-5." + + "7v-15.5c.3-19 2.3-37.2 6.5-45.5c2.5-5 6.7-7.7 11.5-9.1c9.1-2.7 " + + "24.9-.9 31.7-1.2c0 0 20.4.8 20.4-10.7s-9.1-8.6-9.1-8.6c-10 .3-17" + + ".7-.4-22.6-2.4c-8.3-3.3-8.6-9.2-8.6-11.2c-.4-23.1-34.5-25.9-64.5-" + + "20.1c-32.8 6.2.4 53.3.4 116.1v8.4c0 3.1-2.6 5.6-5.7 5.6H57.7c-3.1" + + " 0-5.7-2.5-5.7-5.7v-144c0-3.1 2.5-5.7 5.7-5.7h31.7c3.1 0 5.7 2.5" + + " 5.7 5.7c0 4.7 5.2 7.2 9 4.5c11.4-8.2 26-12.5 42.4-12.5c36.6 0" + + " 64.4 21.4 64.4 68.7v83.2ZM150 99.3c0-6.7-5.4-12.1-12.1-12.1s-" + + "12.1 5.4-12.1 12.1s5.4 12.1 12.1 12.1S150 106 150 99.3Z", + ) + }, + ) + }.build() + } diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model/PlatformTypeIcon.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model/PlatformTypeIcon.kt new file mode 100644 index 000000000..fd185f669 --- /dev/null +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model/PlatformTypeIcon.kt @@ -0,0 +1,23 @@ +package dev.dimension.flare.ui.model + +import androidx.compose.ui.graphics.vector.ImageVector +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Brands +import compose.icons.fontawesomeicons.brands.Bluesky +import compose.icons.fontawesomeicons.brands.Mastodon +import compose.icons.fontawesomeicons.brands.Weibo +import compose.icons.fontawesomeicons.brands.XTwitter +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.icons.Misskey +import dev.dimension.flare.ui.icons.Nostr + +public val PlatformType.brandIcon: ImageVector + get() = + when (this) { + PlatformType.Nostr -> FontAwesomeIcons.Brands.Nostr + PlatformType.Mastodon -> FontAwesomeIcons.Brands.Mastodon + PlatformType.Misskey -> FontAwesomeIcons.Brands.Misskey + PlatformType.Bluesky -> FontAwesomeIcons.Brands.Bluesky + PlatformType.xQt -> FontAwesomeIcons.Brands.XTwitter + PlatformType.VVo -> FontAwesomeIcons.Brands.Weibo + } 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 84c3cf5b6..ec231673b 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 @@ -9,8 +9,7 @@ import dev.dimension.flare.data.model.MixedTimelineTabItem import dev.dimension.flare.data.model.TimelineTabItem 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.model.displayName import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.model.collectAsUiState import dev.dimension.flare.ui.presenter.home.UserPresenter @@ -80,14 +79,7 @@ public class HomeTimelineWithTabsPresenter( val tab = HomeTimelineTabItem( accountKey = account.accountKey, - title = - when (account.platformType) { - PlatformType.Mastodon -> "Mastodon" - PlatformType.Misskey -> "Misskey" - PlatformType.Bluesky -> "Bluesky" - PlatformType.xQt -> "X" - PlatformType.VVo -> vvo - }, + title = account.platformType.displayName, ) settingsRepository.updateTabSettings { if (mainTabs.any { it.key == tab.key }) { diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/NostrInputPresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/NostrInputPresenter.kt new file mode 100644 index 000000000..654864c44 --- /dev/null +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/NostrInputPresenter.kt @@ -0,0 +1,46 @@ +package dev.dimension.flare.ui.screen.login + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import dev.dimension.flare.data.network.nostr.NostrService +import dev.dimension.flare.ui.presenter.PresenterBase +import kotlin.native.HiddenFromObjC + +@HiddenFromObjC +public class NostrInputPresenter : PresenterBase() { + @Immutable + public interface State { + public val publicKey: TextFieldState + public val secretKey: TextFieldState + public val relays: TextFieldState + public val canLogin: Boolean + } + + @Composable + override fun body(): State { + val publicKey = rememberTextFieldState() + val secretKey = rememberTextFieldState() + val relays = + rememberTextFieldState( + initialText = NostrService.defaultRelays.joinToString(", "), + ) + + val canLogin by remember(publicKey, secretKey) { + derivedStateOf { + publicKey.text.isNotEmpty() || secretKey.text.isNotEmpty() + } + } + + return object : State { + override val publicKey: TextFieldState = publicKey + override val secretKey: TextFieldState = secretKey + override val relays: TextFieldState = relays + override val canLogin: Boolean = canLogin + } + } +} diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/SelectionPresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/SelectionPresenter.kt index 33ec30cbc..ff1aefd7d 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/SelectionPresenter.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/SelectionPresenter.kt @@ -23,6 +23,7 @@ public class SelectionPresenter( public interface State : ServiceSelectState { public val instanceInputState: TextFieldState public val blueskyInputState: BlueskyInputPresenter.State + public val nostrInputState: NostrInputPresenter.State public fun selectInstance(instance: UiInstance) @@ -53,10 +54,12 @@ public class SelectionPresenter( } val blueskyInputState = remember { BlueskyInputPresenter() }.body() + val nostrInputState = remember { NostrInputPresenter() }.body() return object : State, ServiceSelectState by baseState { override val instanceInputState: TextFieldState = instanceInputState override val blueskyInputState: BlueskyInputPresenter.State = blueskyInputState + override val nostrInputState: NostrInputPresenter.State = nostrInputState override fun selectInstance(instance: UiInstance) { instanceInputState.edit { diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/ServiceSelectionScreenContent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/ServiceSelectionScreenContent.kt index c737effbb..91e5f181b 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/ServiceSelectionScreenContent.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/ServiceSelectionScreenContent.kt @@ -58,6 +58,12 @@ import dev.dimension.flare.compose.ui.eula_privacy_policy import dev.dimension.flare.compose.ui.login_agreement import dev.dimension.flare.compose.ui.login_button import dev.dimension.flare.compose.ui.mastodon_login_verify_message +import dev.dimension.flare.compose.ui.nostr_login_generate_button +import dev.dimension.flare.compose.ui.nostr_login_hint +import dev.dimension.flare.compose.ui.nostr_login_npub_hint +import dev.dimension.flare.compose.ui.nostr_login_nsec_hint +import dev.dimension.flare.compose.ui.nostr_login_relays_hint +import dev.dimension.flare.compose.ui.nostr_login_title import dev.dimension.flare.compose.ui.service_select_compatibility_warning import dev.dimension.flare.compose.ui.service_select_empty_message import dev.dimension.flare.compose.ui.service_select_instance_input_placeholder @@ -67,6 +73,7 @@ import dev.dimension.flare.compose.ui.service_select_welcome_list_hint import dev.dimension.flare.compose.ui.service_select_welcome_message import dev.dimension.flare.compose.ui.service_select_welcome_title import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.model.agreementUrl import dev.dimension.flare.model.logoUrl import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.NetworkImage @@ -220,6 +227,10 @@ public fun ServiceSelectionScreenContent( val passwordFocusRequester = remember { FocusRequester() } val pinCodeFocusRequester = remember { FocusRequester() } when (nodeData.platformType) { + PlatformType.Nostr -> { + NostrLoginContent(state) + } + PlatformType.Bluesky -> { val oauthString = stringResource(Res.string.bluesky_login_oauth_button) @@ -602,6 +613,139 @@ public fun ServiceSelectionScreenContent( } } +@Composable +private fun NostrLoginContent(state: SelectionPresenter.State) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + NetworkImage( + PlatformType.Nostr.logoUrl, + contentDescription = null, + modifier = Modifier.size(24.dp), + contentScale = ContentScale.Fit, + ) + PlatformText( + text = stringResource(Res.string.nostr_login_title), + style = PlatformTheme.typography.title, + ) + } + PlatformText( + text = stringResource(Res.string.nostr_login_hint), + textAlign = TextAlign.Center, + style = PlatformTheme.typography.caption, + ) + PlatformTextField( + state = state.nostrInputState.publicKey, + label = { + PlatformText(text = stringResource(Res.string.nostr_login_npub_hint)) + }, + enabled = !state.nostrLoginState.loading, + modifier = Modifier.width(300.dp), + lineLimits = TextFieldLineLimits.SingleLine, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + autoCorrectEnabled = false, + ), + ) + PlatformSecureTextField( + state = state.nostrInputState.secretKey, + label = { + PlatformText(text = stringResource(Res.string.nostr_login_nsec_hint)) + }, + enabled = !state.nostrLoginState.loading, + modifier = Modifier.width(300.dp), + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next, + autoCorrectEnabled = false, + ), + ) + PlatformTextField( + state = state.nostrInputState.relays, + label = { + PlatformText(text = stringResource(Res.string.nostr_login_relays_hint)) + }, + enabled = !state.nostrLoginState.loading, + modifier = Modifier.width(300.dp), + lineLimits = TextFieldLineLimits.SingleLine, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done, + autoCorrectEnabled = false, + ), + onKeyboardAction = { + if (state.nostrInputState.canLogin) { + state.nostrLoginState.login( + publicKey = + state.nostrInputState.publicKey.text + .toString(), + secretKey = + state.nostrInputState.secretKey.text + .toString(), + relays = + state.nostrInputState.relays.text + .toString(), + ) + } + }, + ) + PlatformFilledTonalButton( + onClick = { + state.nostrLoginState.login( + publicKey = + state.nostrInputState.publicKey.text + .toString(), + secretKey = + state.nostrInputState.secretKey.text + .toString(), + relays = + state.nostrInputState.relays.text + .toString(), + ) + }, + modifier = Modifier.width(300.dp), + enabled = state.nostrInputState.canLogin && !state.nostrLoginState.loading, + ) { + PlatformText(text = stringResource(Res.string.login_button)) + } + PlatformFilledTonalButton( + onClick = { + state.nostrLoginState.generateAndLogin( + relays = + state.nostrInputState.relays.text + .toString(), + ) + }, + modifier = Modifier.width(300.dp), + enabled = !state.nostrLoginState.loading, + ) { + PlatformText(text = stringResource(Res.string.nostr_login_generate_button)) + } + state.nostrLoginState.error?.let { + PlatformText( + text = it.message ?: "Unknown error", + textAlign = TextAlign.Center, + ) + } + if (state.nostrLoginState.loading) { + PlatformLinearProgressIndicator() + } + } +} + @Composable private fun ServiceSelectItem( instance: UiInstance?, @@ -677,18 +821,12 @@ private fun LoginAgreement( openUri: (String) -> Unit, modifier: Modifier = Modifier, ) { - if (platformType == PlatformType.VVo) return + val url = platformType.agreementUrl(host) ?: return val linkText = stringResource(Res.string.eula_privacy_policy) val fullText = stringResource(Res.string.login_agreement, linkText) val color = PlatformTheme.colorScheme.primary val annotatedString = - remember { - val url = - when (platformType) { - PlatformType.Bluesky -> "https://bsky.social/about/support/tos" - PlatformType.xQt -> "https://help.x.com/en/rules-and-policies/x-rules" - else -> "https://$host/about" - } + remember(platformType, host, url, linkText, fullText, color) { buildAnnotatedString { append(fullText) val startIndex = fullText.indexOf(linkText) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d53aec8af..f2b9bbd36 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -65,6 +65,7 @@ zoomable = "0.18.0" bouncycastle = "1.83" cupertino = "2.3.1" robolectric = "4.16.1" +quartz = "1.04.2" [libraries] @@ -174,6 +175,7 @@ ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-conte ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-serialization-kotlinx-xml = { group = "io.ktor", name = "ktor-serialization-kotlinx-xml", version.ref = "ktor" } ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" } +ktor-client-websockets = { group = "io.ktor", name = "ktor-client-websockets", version.ref = "ktor" } ktor-client-darwin = { group = "io.ktor", name = "ktor-client-darwin", version.ref = "ktor" } ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } ktor-client-curl = { group = "io.ktor", name = "ktor-client-curl", version.ref = "ktor" } @@ -218,6 +220,8 @@ fluent-ui = { module = "io.github.compose-fluent:fluent", version = "0.2.0-SNAPS commons-lang3 = { module = "org.apache.commons:commons-lang3", version = "3.20.0" } +quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } + jSystemThemeDetector = { module = "com.github.Dansoftowner:jSystemThemeDetector", version = "3.9.1" } androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } @@ -249,7 +253,7 @@ ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref compose = ["ui", "ui-util", "ui-graphics", "ui-tooling", "ui-tooling-preview", "material3", "material3WindowSizeClass", "material3-adaptive-navigation-suite", "material3-adaptive", "material3-adaptive-navigation", "material3-adaptive-layout"] kotlinx = ["kotlinx-datetime", "kotlinx-immutable", "kotlinx-serialization-json", "kotlinx-coroutines-core"] koin = ["koin-core", "koin-android", "koin-compose", "koin-androidx-compose"] -ktor = ["ktor-client-content-negotiation", "ktor-serialization-kotlinx-json", "ktor-client-logging", "ktor-serialization-kotlinx-xml"] +ktor = ["ktor-client-content-negotiation", "ktor-serialization-kotlinx-json", "ktor-client-logging", "ktor-client-websockets", "ktor-serialization-kotlinx-xml"] coil3 = ["coil3-compose", "coil3-svg", "coil3-ktor3", "coil3-network"] coil3-extensions = ["apng", "awebp", "vectordrawable-animated", "zoomable-image", "coil3-gif", "coil3-video"] accompanist = ["accompanist-permissions", "accompanist-drawablepainter"] @@ -279,4 +283,4 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = " room = { id = "androidx.room", version.ref = "room" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.6.6" } -nucleus = { id = "io.github.kdroidfilter.nucleus", version = "1.3.8" } \ No newline at end of file +nucleus = { id = "io.github.kdroidfilter.nucleus", version = "1.3.8" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index e3937f5b2..17bac7da0 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -88,6 +88,7 @@ kotlin { implementation(libs.ktor.client.resources) implementation(libs.cryptography.provider.optimal) implementation(libs.openai.client) + implementation(libs.quartz) } } val commonTest by getting { diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/common/deeplink/DeepLinkMapping.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/common/deeplink/DeepLinkMapping.kt index 9d20d10f0..72eb75878 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/common/deeplink/DeepLinkMapping.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/common/deeplink/DeepLinkMapping.kt @@ -88,6 +88,8 @@ internal object DeepLinkMapping { host: String, ): List> = when (platformType) { + PlatformType.Nostr -> emptyList() + PlatformType.Mastodon -> { listOf( DeepLinkPattern( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt new file mode 100644 index 000000000..45cf77bd8 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt @@ -0,0 +1,211 @@ +package dev.dimension.flare.data.datasource.nostr + +import dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataSource +import dev.dimension.flare.data.datasource.microblog.ComposeConfig +import dev.dimension.flare.data.datasource.microblog.ComposeData +import dev.dimension.flare.data.datasource.microblog.ComposeType +import dev.dimension.flare.data.datasource.microblog.NotificationFilter +import dev.dimension.flare.data.datasource.microblog.ProfileTab +import dev.dimension.flare.data.datasource.microblog.datasource.RelationDataSource +import dev.dimension.flare.data.datasource.microblog.datasource.UserDataSource +import dev.dimension.flare.data.datasource.microblog.handler.RelationHandler +import dev.dimension.flare.data.datasource.microblog.handler.UserHandler +import dev.dimension.flare.data.datasource.microblog.loader.RelationActionType +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader +import dev.dimension.flare.data.datasource.microblog.paging.notSupported +import dev.dimension.flare.data.network.nostr.NostrService +import dev.dimension.flare.data.repository.AccountRepository +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiAccount +import dev.dimension.flare.ui.model.UiHashtag +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.UiTimelineV2 +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.first +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +internal class NostrDataSource( + override val accountKey: MicroBlogKey, + private val relayHint: String? = null, +) : AuthenticatedMicroblogDataSource, + UserDataSource, + RelationDataSource, + KoinComponent { + private val accountRepository: AccountRepository by inject() + private val loader by lazy { + NostrLoader( + accountKey = accountKey, + credentialProvider = { + accountRepository + .credentialFlow(accountKey) + .first() + .let { + if (relayHint != null && relayHint !in it.relays) { + it.copy(relays = listOf(relayHint) + it.relays) + } else { + it + } + } + }, + ) + } + + override val supportedNotificationFilter: List = emptyList() + override val userHandler by lazy { + UserHandler( + host = NostrService.NOSTR_HOST, + loader = loader, + ) + } + override val relationHandler by lazy { + RelationHandler( + accountType = + dev.dimension.flare.model.AccountType + .Specific(accountKey), + dataSource = loader, + ) + } + override val supportedRelationTypes: Set + get() = loader.supportedTypes + + override fun homeTimeline(): RemoteLoader = + object : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend) { + return PagingResult(endOfPaginationReached = true) + } + val credential = + accountRepository + .credentialFlow(accountKey) + .first() + .let { + if (relayHint != null && relayHint !in it.relays) { + it.copy(relays = listOf(relayHint) + it.relays) + } else { + it + } + } + val until = + when (request) { + PagingRequest.Refresh -> null + is PagingRequest.Append -> request.nextKey.toLongOrNull() + is PagingRequest.Prepend -> null + } + val data = + NostrService.loadHomeTimeline( + credential = credential, + accountKey = accountKey, + pageSize = pageSize, + until = until, + ) + val nextKey = + data + .filterIsInstance() + .minOfOrNull { it.createdAt.value.epochSeconds - 1 } + ?.takeIf { data.isNotEmpty() } + ?.toString() + return PagingResult( + endOfPaginationReached = data.isEmpty(), + data = data, + nextKey = nextKey, + ) + } + } + + override fun userTimeline( + userKey: MicroBlogKey, + mediaOnly: Boolean, + ): RemoteLoader = + object : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend) { + return PagingResult(endOfPaginationReached = true) + } + val credential = + accountRepository + .credentialFlow(accountKey) + .first() + .let { + if (relayHint != null && relayHint !in it.relays) { + it.copy(relays = listOf(relayHint) + it.relays) + } else { + it + } + } + val until = + when (request) { + PagingRequest.Refresh -> null + is PagingRequest.Append -> request.nextKey.toLongOrNull() + is PagingRequest.Prepend -> null + } + val data = + NostrService.loadUserTimeline( + credential = credential, + accountKey = accountKey, + targetPubkey = userKey.id, + pageSize = pageSize, + until = until, + mediaOnly = mediaOnly, + ) + val nextKey = + data + .filterIsInstance() + .minOfOrNull { it.createdAt.value.epochSeconds - 1 } + ?.takeIf { data.isNotEmpty() } + ?.toString() + return PagingResult( + endOfPaginationReached = data.isEmpty(), + data = data, + nextKey = nextKey, + ) + } + } + + override fun context(statusKey: MicroBlogKey): RemoteLoader = notSupported() + + override fun searchStatus(query: String): RemoteLoader = notSupported() + + override fun searchUser(query: String): RemoteLoader = notSupported() + + override fun discoverUsers(): RemoteLoader = notSupported() + + override fun discoverStatuses(): RemoteLoader = notSupported() + + override fun discoverHashtags(): RemoteLoader = notSupported() + + override fun following(userKey: MicroBlogKey): RemoteLoader = notSupported() + + override fun fans(userKey: MicroBlogKey): RemoteLoader = notSupported() + + override fun profileTabs(userKey: MicroBlogKey): ImmutableList = + persistentListOf( + ProfileTab.Timeline( + type = ProfileTab.Timeline.Type.Status, + loader = userTimeline(userKey = userKey, mediaOnly = false), + ), + ) + + override fun notification(type: NotificationFilter): RemoteLoader = notSupported() + + override suspend fun compose( + data: ComposeData, + progress: () -> Unit, + ) { + error("Nostr compose is not implemented yet. relayHint=$relayHint") + } + + override fun composeConfig(type: ComposeType): ComposeConfig = + ComposeConfig( + text = ComposeConfig.Text(65535), + ) +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrLoader.kt new file mode 100644 index 000000000..18f220f21 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrLoader.kt @@ -0,0 +1,52 @@ +package dev.dimension.flare.data.datasource.nostr + +import dev.dimension.flare.data.datasource.microblog.loader.RelationActionType +import dev.dimension.flare.data.datasource.microblog.loader.RelationLoader +import dev.dimension.flare.data.datasource.microblog.loader.UserLoader +import dev.dimension.flare.data.network.nostr.NostrService +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiAccount +import dev.dimension.flare.ui.model.UiHandle +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.UiRelation + +internal class NostrLoader( + private val accountKey: MicroBlogKey, + private val credentialProvider: suspend () -> UiAccount.Nostr.Credential, +) : UserLoader, + RelationLoader { + override val supportedTypes: Set = emptySet() + + override suspend fun userByHandleAndHost(uiHandle: UiHandle): UiProfile = + NostrService.loadProfile( + credential = credentialProvider(), + accountKey = accountKey, + targetPubkey = uiHandle.normalizedRaw, + ) + + override suspend fun userById(id: String): UiProfile = + NostrService.loadProfile( + credential = credentialProvider(), + accountKey = accountKey, + targetPubkey = id, + ) + + override suspend fun relation(userKey: MicroBlogKey): UiRelation = + NostrService.relation( + credential = credentialProvider(), + targetPubkey = userKey.id, + ) + + override suspend fun follow(userKey: MicroBlogKey): Unit = throw UnsupportedOperationException("Nostr follow is not implemented yet") + + override suspend fun unfollow(userKey: MicroBlogKey): Unit = + throw UnsupportedOperationException("Nostr unfollow is not implemented yet") + + override suspend fun block(userKey: MicroBlogKey): Unit = throw UnsupportedOperationException("Nostr block is not implemented yet") + + override suspend fun unblock(userKey: MicroBlogKey): Unit = throw UnsupportedOperationException("Nostr unblock is not implemented yet") + + override suspend fun mute(userKey: MicroBlogKey): Unit = throw UnsupportedOperationException("Nostr mute is not implemented yet") + + override suspend fun unmute(userKey: MicroBlogKey): Unit = throw UnsupportedOperationException("Nostr unmute is not implemented yet") +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt new file mode 100644 index 000000000..1443b6674 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt @@ -0,0 +1,550 @@ +package dev.dimension.flare.data.network.nostr + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip19Bech32.entities.NSec +import com.vitorpamplona.quartz.nip19Bech32.toNsec +import dev.dimension.flare.common.JSON +import dev.dimension.flare.data.network.ktorClient +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.model.ClickEvent +import dev.dimension.flare.ui.model.UiAccount +import dev.dimension.flare.ui.model.UiHandle +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.render.toUi +import dev.dimension.flare.ui.render.toUiPlainText +import dev.dimension.flare.ui.route.DeeplinkRoute +import dev.whyoleg.cryptography.random.CryptographyRandom +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import kotlin.time.Clock +import kotlin.time.Instant + +internal object NostrService { + internal const val NOSTR_HOST: String = "nostr" + + internal val defaultRelays: List = + listOf( + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.primal.net", + "wss://relay.snort.social", + "wss://relay.nostr.band", + "wss://offchain.pub", + "wss://purplepag.es", + ) + + internal data class ImportedAccount( + val pubkeyHex: String, + val npub: String, + val nsec: String?, + val relays: List, + ) + + internal fun importAccount( + publicKeyInput: String, + secretKeyInput: String, + relayInput: String, + ): ImportedAccount { + val normalizedSecret = secretKeyInput.trim().takeIf { it.isNotEmpty() }?.let(::normalizeSecret) + val normalizedPublic = publicKeyInput.trim().takeIf { it.isNotEmpty() }?.let(::normalizePublic) + + val secretHex = normalizedSecret?.hex + val explicitPubkeyHex = normalizedPublic?.hex + val derivedPubkeyHex = secretHex?.let { NSec(it).toPubKeyHex() } + val pubkeyHex = explicitPubkeyHex ?: derivedPubkeyHex ?: error("A public key or secret key is required") + if (explicitPubkeyHex != null && derivedPubkeyHex != null) { + require(explicitPubkeyHex == derivedPubkeyHex) { + "Public key does not match the provided secret key" + } + } + + val normalizedRelays = normalizeRelays(relayInput) + return ImportedAccount( + pubkeyHex = pubkeyHex, + npub = NPub.Companion.create(pubkeyHex), + nsec = secretHex?.hexToByteArray()?.toNsec(), + relays = normalizedRelays, + ) + } + + internal fun generateAccount(relayInput: String): ImportedAccount { + while (true) { + val secretHex = CryptographyRandom.nextBytes(32).toHexString() + runCatching { + return importAccount( + publicKeyInput = "", + secretKeyInput = secretHex, + relayInput = relayInput, + ) + } + } + } + + internal fun exportAccount(credential: UiAccount.Nostr.Credential): ImportedAccount { + val secretKey = + requireNotNull(credential.nsec) { + "Nostr account does not have an exportable private key" + } + return importAccount( + publicKeyInput = credential.pubkey, + secretKeyInput = secretKey, + relayInput = credential.relays.joinToString(","), + ) + } + + internal suspend fun loadHomeTimeline( + credential: UiAccount.Nostr.Credential, + accountKey: MicroBlogKey, + pageSize: Int, + until: Long?, + ): List { + val relays = credential.relays.ifEmpty { defaultRelays } + val authors = loadAuthors(relays, credential.pubkey) + val events = + queryFirstRelay( + relays = relays, + filters = + listOf( + Filter( + authors = authors, + kinds = listOf(TextNoteEvent.KIND), + until = until, + limit = pageSize, + ), + ), + ).filterIsInstance() + .sortedByDescending { it.createdAt } + + if (events.isEmpty()) { + return emptyList() + } + + val metadata = loadMetadata(relays, events.map { it.pubKey }.distinct()) + return events.map { event -> + event.toUi( + accountKey = accountKey, + metadata = metadata[event.pubKey], + ) + } + } + + internal suspend fun loadProfile( + credential: UiAccount.Nostr.Credential, + accountKey: MicroBlogKey, + targetPubkey: String, + ): UiProfile { + val relays = credential.relays.ifEmpty { defaultRelays } + val metadata = + loadMetadata( + relays = relays, + authors = listOf(targetPubkey), + )[targetPubkey] + return profileOf( + pubKey = targetPubkey, + metadata = metadata, + accountKey = accountKey, + ) + } + + internal suspend fun loadUserTimeline( + credential: UiAccount.Nostr.Credential, + accountKey: MicroBlogKey, + targetPubkey: String, + pageSize: Int, + until: Long?, + mediaOnly: Boolean, + ): List { + if (mediaOnly) { + return emptyList() + } + val relays = credential.relays.ifEmpty { defaultRelays } + val events = + queryAllRelays( + relays = relays, + filters = + listOf( + Filter( + authors = listOf(targetPubkey), + kinds = listOf(TextNoteEvent.KIND), + until = until, + limit = pageSize, + ), + ), + ).filterIsInstance() + .sortedByDescending { it.createdAt } + if (events.isEmpty()) { + return emptyList() + } + val metadata = loadMetadata(relays, listOf(targetPubkey)) + return events.map { event -> + event.toUi( + accountKey = accountKey, + metadata = metadata[event.pubKey], + ) + } + } + + internal suspend fun relation( + credential: UiAccount.Nostr.Credential, + targetPubkey: String, + ): dev.dimension.flare.ui.model.UiRelation { + val relays = credential.relays.ifEmpty { defaultRelays } + val follows = loadAuthors(relays, credential.pubkey) + return dev.dimension.flare.ui.model.UiRelation( + following = targetPubkey in follows, + ) + } + + internal fun createTextNoteEvent( + secretKey: String, + content: String, + createdAt: Long = Clock.System.now().toEpochMilliseconds() / 1000, + ): TextNoteEvent { + val imported = importAccount(publicKeyInput = "", secretKeyInput = secretKey, relayInput = "") + return signEvent( + pubkeyHex = imported.pubkeyHex, + secretKey = requireNotNull(imported.nsec), + template = TextNoteEvent.Companion.build(content, createdAt) {}, + ) + } + + private suspend fun loadAuthors( + relays: List, + accountPubkey: String, + ): List { + val latestContacts = + queryAllRelays( + relays = relays, + filters = + listOf( + Filter( + authors = listOf(accountPubkey), + kinds = listOf(ContactListEvent.KIND), + limit = 1, + ), + ), + ).filterIsInstance() + .maxByOrNull { it.createdAt } + + return (latestContacts?.verifiedFollowKeySet().orEmpty() + accountPubkey) + .distinct() + .take(MAX_HOME_AUTHORS) + } + + private suspend fun loadMetadata( + relays: List, + authors: List, + ): Map { + if (authors.isEmpty()) { + return emptyMap() + } + val latestByPubkey = + queryAllRelays( + relays = relays, + filters = + listOf( + Filter( + authors = authors, + kinds = listOf(MetadataEvent.KIND), + limit = maxOf(authors.size * 3, MIN_METADATA_EVENT_LIMIT), + ), + ), + ).filterIsInstance() + .groupBy { it.pubKey } + .mapValues { (_, values) -> values.maxByOrNull { it.createdAt } } + + return buildMap { + latestByPubkey.forEach { (pubkey, event) -> + event?.runCatching { contactMetaData() }?.getOrNull()?.let { + put(pubkey, it) + } + } + } + } + + private suspend fun queryFirstRelay( + relays: List, + filters: List, + ): List = + relays + .firstNotNullOfOrNull { relay -> + runCatching { queryRelay(relay, filters) }.getOrNull()?.takeIf { it.isNotEmpty() } + }.orEmpty() + + private suspend fun queryAllRelays( + relays: List, + filters: List, + ): List = + coroutineScope { + relays + .map { relay -> + async { + runCatching { queryRelay(relay, filters) }.getOrDefault(emptyList()) + } + }.awaitAll() + .flatten() + .distinctBy { it.id } + } + + private suspend fun queryRelay( + relay: String, + filters: List, + ): List { + val client = + ktorClient { + install(WebSockets) + } + val session = client.webSocketSession(urlString = relay) + val subscriptionId = "flare-${Clock.System.now().toEpochMilliseconds()}" + val events = mutableListOf() + return try { + session.send( + Frame.Text( + buildString { + append("[\"REQ\",\"") + append(subscriptionId) + append("\"") + filters.forEach { filter -> + append(",") + append(filter.toJson()) + } + append("]") + }, + ), + ) + withTimeoutOrNull(RELAY_TIMEOUT_MILLIS) { + while (true) { + when (val frame = session.incoming.receive()) { + is Frame.Text -> { + when (val message = parseRelayMessage(frame.data.decodeToString())) { + RelayMessage.EndOfStoredEvents -> break + is RelayMessage.EventEnvelope -> events += message.event + null -> Unit + } + } + + else -> Unit + } + } + } + events + } finally { + runCatching { session.send(Frame.Text("[\"CLOSE\",\"$subscriptionId\"]")) } + runCatching { session.close() } + client.close() + } + } + + private fun normalizeRelays(relayInput: String): List { + val candidates = + relayInput + .split(Regex("[,\\n\\r\\t ]+")) + .map(String::trim) + .filter(String::isNotEmpty) + .ifEmpty { defaultRelays } + + return candidates + .map { + RelayUrlNormalizer.Companion.normalizeOrNull(it)?.url + ?: error("Invalid relay URL: $it") + }.distinct() + } + + private fun normalizeSecret(raw: String): NSec { + val value = raw.removePrefix("nostr:").trim() + return when { + value.startsWith("nsec1", ignoreCase = true) -> { + Nip19Parser + .parseAll(value) + .singleOrNull() + ?.let { it as? NSec } + ?: error("Invalid NIP-19 secret key") + } + + HEX_KEY_REGEX.matches(value) -> NSec(value) + + else -> error("Unsupported secret key format") + } + } + + private fun normalizePublic(raw: String): NPub { + val value = raw.removePrefix("nostr:").trim() + return when { + value.startsWith("npub1", ignoreCase = true) -> { + Nip19Parser + .parseAll(value) + .singleOrNull() + ?.let { it as? NPub } + ?: error("Invalid NIP-19 public key") + } + + HEX_KEY_REGEX.matches(value) -> NPub(value) + + else -> error("Unsupported public key format") + } + } + + private fun signEvent( + pubkeyHex: String, + secretKey: String, + template: EventTemplate, + ): T = + template.sign( + pubKey = pubkeyHex, + privKey = NSec(secretKey.removePrefix("nostr:")).hex.hexToByteArray(), + pubKeyByteArray = pubkeyHex.hexToByteArray(), + ) + + private fun EventTemplate.sign( + pubKey: String, + privKey: ByteArray, + pubKeyByteArray: ByteArray, + ): T = + com.vitorpamplona.quartz.nip01Core.crypto.EventAssembler.Companion.hashAndSign( + pubKey, + createdAt, + kind, + tags, + content, + privKey, + pubKeyByteArray, + ) + + private fun parseRelayMessage(raw: String): RelayMessage? = + runCatching { + val payload = JSON.parseToJsonElement(raw).jsonArray + when (payload.firstOrNull()?.jsonPrimitive?.contentOrNull) { + "EVENT" -> + payload.getOrNull(2)?.let { + RelayMessage.EventEnvelope( + Event.Companion.fromJson(it.toString()), + ) + } + + "EOSE" -> RelayMessage.EndOfStoredEvents + else -> null + } + }.getOrNull() + + private fun ByteArray.toHexString(): String { + val chars = CharArray(size * 2) + forEachIndexed { index, byte -> + val value = byte.toInt() and 0xff + chars[index * 2] = HEX_DIGITS[value ushr 4] + chars[index * 2 + 1] = HEX_DIGITS[value and 0x0f] + } + return chars.concatToString() + } + + private fun TextNoteEvent.toUi( + accountKey: MicroBlogKey, + metadata: UserMetadata?, + ): UiTimelineV2.Post = + UiTimelineV2.Post( + message = null, + platformType = PlatformType.Nostr, + images = persistentListOf(), + sensitive = false, + contentWarning = null, + user = profileOf(pubKey, metadata, accountKey), + quote = persistentListOf(), + content = content.toUiPlainText(), + actions = persistentListOf(), + poll = null, + statusKey = MicroBlogKey(id, NOSTR_HOST), + card = null, + createdAt = Instant.fromEpochSeconds(createdAt).toUi(), + emojiReactions = persistentListOf(), + sourceChannel = null, + visibility = UiTimelineV2.Post.Visibility.Public, + replyToHandle = null, + references = persistentListOf(), + parents = persistentListOf(), + internalRepost = null, + clickEvent = ClickEvent.Noop, + extraKey = null, + accountType = AccountType.Specific(accountKey), + ) + + private fun profileOf( + pubKey: String, + metadata: UserMetadata?, + accountKey: MicroBlogKey, + ): UiProfile { + val bestName = metadata?.bestName().orEmpty() + val npub = NPub.Companion.create(pubKey) + return UiProfile( + key = MicroBlogKey(pubKey, NOSTR_HOST), + handle = + UiHandle( + raw = bestName.ifBlank { npub.take(16) }, + host = NOSTR_HOST, + ), + avatar = metadata?.picture.orEmpty(), + nameInternal = bestName.ifBlank { npub.take(16) }.toUiPlainText(), + platformType = PlatformType.Nostr, + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.Profile.User( + accountType = AccountType.Specific(accountKey), + userKey = MicroBlogKey(pubKey, NOSTR_HOST), + ), + ), + banner = metadata?.banner, + description = metadata?.about?.takeIf { it.isNotBlank() }?.toUiPlainText(), + matrices = + UiProfile.Matrices( + fansCount = 0, + followsCount = 0, + statusesCount = 0, + ), + mark = + listOfNotNull( + if (metadata?.bot == true) { + UiProfile.Mark.Bot + } else { + null + }, + ).toImmutableList(), + bottomContent = null, + ) + } + + private sealed interface RelayMessage { + data class EventEnvelope( + val event: Event, + ) : RelayMessage + + data object EndOfStoredEvents : RelayMessage + } + + private val HEX_KEY_REGEX = Regex("^[0-9a-fA-F]{64}\$") + private val HEX_DIGITS = "0123456789abcdef".toCharArray() + private const val RELAY_TIMEOUT_MILLIS = 8_000L + private const val MAX_HOME_AUTHORS = 250 + private const val MIN_METADATA_EVENT_LIMIT = 50 +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformType.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformType.kt index b6b31ec80..36dbfa2d3 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformType.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformType.kt @@ -7,6 +7,7 @@ import kotlin.io.encoding.Base64 @Immutable @Serializable public enum class PlatformType { + Nostr, Mastodon, Misskey, Bluesky, @@ -17,22 +18,71 @@ public enum class PlatformType { VVo, } -public val PlatformType.logoUrl: String +@Immutable +public data class PlatformTypeMetadata( + val displayName: String, + val logoUrl: String, +) + +public val PlatformType.metadata: PlatformTypeMetadata get() = when (this) { - PlatformType.Mastodon -> "https://joinmastodon.org/logos/logo-purple.svg" + PlatformType.Nostr -> + PlatformTypeMetadata( + displayName = "Nostr", + logoUrl = "https://nostr.com/favicon.ico", + ) + PlatformType.Mastodon -> + PlatformTypeMetadata( + displayName = "Mastodon", + logoUrl = "https://joinmastodon.org/logos/logo-purple.svg", + ) PlatformType.Misskey -> - "https://github.com/misskey-dev/misskey/blob/develop/packages" + - "/backend/assets/favicon.png?raw=true" - PlatformType.Bluesky -> "https://blueskyweb.xyz/images/apple-touch-icon.png" + PlatformTypeMetadata( + displayName = "Misskey", + logoUrl = + "https://github.com/misskey-dev/misskey/blob/develop/packages" + + "/backend/assets/favicon.png?raw=true", + ) + PlatformType.Bluesky -> + PlatformTypeMetadata( + displayName = "Bluesky", + logoUrl = "https://blueskyweb.xyz/images/apple-touch-icon.png", + ) PlatformType.xQt -> - "https://upload.wikimedia.org/wikipedia/commons/thumb/5/53" + - "/X_logo_2023_original.svg/1920px-X_logo_2023_original.svg.png" + PlatformTypeMetadata( + displayName = "X", + logoUrl = + "https://upload.wikimedia.org/wikipedia/commons/thumb/5/53" + + "/X_logo_2023_original.svg/1920px-X_logo_2023_original.svg.png", + ) PlatformType.VVo -> - "https://upload.wikimedia.org/wikipedia/en/thumb/6/" + - "6e/Sina_Weibo.svg/2560px-Sina_Weibo.svg.png" + PlatformTypeMetadata( + displayName = vvo, + logoUrl = + "https://upload.wikimedia.org/wikipedia/en/thumb/6/" + + "6e/Sina_Weibo.svg/2560px-Sina_Weibo.svg.png", + ) } +public val PlatformType.displayName: String + get() = metadata.displayName + +public val PlatformType.logoUrl: String + get() = metadata.logoUrl + +public fun PlatformType.agreementUrl(host: String): String? = + when (this) { + PlatformType.Nostr, + PlatformType.VVo, + -> null + PlatformType.Bluesky -> "https://bsky.social/about/support/tos" + PlatformType.xQt -> "https://help.x.com/en/rules-and-policies/x-rules" + PlatformType.Mastodon, + PlatformType.Misskey, + -> "https://$host/about" + } + public val xqtOldHost: String = buildString { append(Base64.decode("dHc=").decodeToString()) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiAccount.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiAccount.kt index e7a0dda7f..5b0eec73f 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiAccount.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiAccount.kt @@ -7,6 +7,7 @@ import dev.dimension.flare.data.datasource.bluesky.BlueskyDataSource import dev.dimension.flare.data.datasource.mastodon.MastodonDataSource import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource import dev.dimension.flare.data.datasource.misskey.MisskeyDataSource +import dev.dimension.flare.data.datasource.nostr.NostrDataSource import dev.dimension.flare.data.datasource.pleroma.PleromaDataSource import dev.dimension.flare.data.datasource.vvo.VVODataSource import dev.dimension.flare.data.datasource.xqt.XQTDataSource @@ -25,6 +26,24 @@ public sealed class UiAccount { @Serializable internal sealed interface Credential + @Immutable + internal data class Nostr( + override val accountKey: MicroBlogKey, + internal val relayHint: String? = null, + ) : UiAccount() { + override val platformType: PlatformType + get() = PlatformType.Nostr + + @Immutable + @Serializable + @SerialName("NostrCredential") + data class Credential( + val pubkey: String, + val nsec: String? = null, + val relays: List = emptyList(), + ) : UiAccount.Credential + } + @Immutable internal data class Mastodon( override val accountKey: MicroBlogKey, @@ -162,6 +181,12 @@ public sealed class UiAccount { internal companion object { fun UiAccount.createDataSource(): MicroblogDataSource = when (this) { + is Nostr -> + NostrDataSource( + accountKey = accountKey, + relayHint = relayHint, + ) + is Mastodon -> when (forkType) { Mastodon.Credential.ForkType.Mastodon -> @@ -201,6 +226,14 @@ public sealed class UiAccount { fun DbAccount.toUi(): UiAccount = when (platform_type) { + PlatformType.Nostr -> { + val credential = credential_json.decodeJson() + Nostr( + accountKey = account_key, + relayHint = credential.relays.firstOrNull(), + ) + } + PlatformType.Mastodon -> { val credential = credential_json.decodeJson() Mastodon( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiApplication.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiApplication.kt index c9312c829..7b3baaa37 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiApplication.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiApplication.kt @@ -12,6 +12,11 @@ import dev.dimension.flare.model.xqtHost public sealed interface UiApplication { public val host: String + @Immutable + public data class Nostr internal constructor( + override val host: String, + ) : UiApplication + @Immutable public data class Mastodon internal constructor( override val host: String, @@ -43,6 +48,11 @@ public sealed interface UiApplication { public companion object { internal fun DbApplication.toUi(): UiApplication = when (platform_type) { + PlatformType.Nostr -> + Nostr( + host = host, + ) + PlatformType.Mastodon -> Mastodon( host = host, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/InstanceMetadataPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/InstanceMetadataPresenter.kt index 0bf62e7b5..dc9931827 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/InstanceMetadataPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/InstanceMetadataPresenter.kt @@ -32,6 +32,9 @@ public class InstanceMetadataPresenter( tryRun { emit(UiState.Loading()) when (platformType) { + PlatformType.Nostr -> throw UnsupportedOperationException( + "Nostr is not supported yet", + ) PlatformType.Mastodon -> MastodonInstanceService("https://$host/").instance().render() PlatformType.Misskey -> diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/NostrLoginPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/NostrLoginPresenter.kt new file mode 100644 index 000000000..908f378bb --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/NostrLoginPresenter.kt @@ -0,0 +1,106 @@ +package dev.dimension.flare.ui.presenter.login + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dev.dimension.flare.data.network.nostr.NostrService +import dev.dimension.flare.data.repository.AccountRepository +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiAccount +import dev.dimension.flare.ui.presenter.PresenterBase +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +public class NostrLoginPresenter( + private val toHome: () -> Unit, +) : PresenterBase(), + KoinComponent { + private val accountRepository: AccountRepository by inject() + + @Composable + override fun body(): NostrLoginState { + var loading by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + return object : NostrLoginState { + override val loading: Boolean = loading + override val error: Throwable? = error + + override fun login( + publicKey: String, + secretKey: String, + relays: String, + ) { + scope.launch { + loading = true + error = null + runCatching { + loginWith( + NostrService.importAccount( + publicKeyInput = publicKey, + secretKeyInput = secretKey, + relayInput = relays, + ), + ) + }.onFailure { + error = it + } + loading = false + } + } + + override fun generateAndLogin(relays: String) { + scope.launch { + loading = true + error = null + runCatching { + loginWith(NostrService.generateAccount(relayInput = relays)) + }.onFailure { + error = it + } + loading = false + } + } + + private suspend fun loginWith(imported: NostrService.ImportedAccount) { + accountRepository.addAccount( + account = + UiAccount.Nostr( + accountKey = + MicroBlogKey( + id = imported.pubkeyHex, + host = NostrService.NOSTR_HOST, + ), + relayHint = imported.relays.firstOrNull(), + ), + credential = + UiAccount.Nostr.Credential( + pubkey = imported.pubkeyHex, + nsec = imported.nsec, + relays = imported.relays, + ), + ) + toHome.invoke() + } + } + } +} + +@Immutable +public interface NostrLoginState { + public val loading: Boolean + public val error: Throwable? + + public fun login( + publicKey: String, + secretKey: String, + relays: String, + ) + + public fun generateAndLogin(relays: String) +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/ServiceSelectPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/ServiceSelectPresenter.kt index 68601a71a..204b7610a 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/ServiceSelectPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/ServiceSelectPresenter.kt @@ -23,18 +23,21 @@ public class ServiceSelectPresenter( @Composable override fun body(): ServiceSelectState { val nodeInfoState = remember { NodeInfoPresenter() }.body() + val nostrLoginState = remember { NostrLoginPresenter(toHome) }.body() val blueskyLoginState = remember { BlueskyLoginPresenter(toHome) }.body() val blueskyOauthLoginState = remember { BlueskyOAuthLoginPresenter(toHome) }.body() val mastodonLoginState = mastodonLoginPresenter(toHome) val misskeyLoginState = misskeyLoginPresenter(toHome) val loading = - blueskyLoginState.loading || + nostrLoginState.loading || + blueskyLoginState.loading || mastodonLoginState.loading || mastodonLoginState.resumedState is UiState.Loading || misskeyLoginState.loading || misskeyLoginState.resumedState is UiState.Loading return object : ServiceSelectState, NodeInfoState by nodeInfoState { + override val nostrLoginState = nostrLoginState override val blueskyLoginState = blueskyLoginState override val blueskyOauthLoginState = blueskyOauthLoginState override val mastodonLoginState = mastodonLoginState @@ -144,6 +147,7 @@ public class ServiceSelectPresenter( @Immutable public interface ServiceSelectState : NodeInfoState { + public val nostrLoginState: NostrLoginState public val blueskyLoginState: BlueskyLoginState public val blueskyOauthLoginState: BlueskyOAuthLoginPresenter.State public val mastodonLoginState: MastodonLoginState diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/network/nostr/NostrServiceTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/network/nostr/NostrServiceTest.kt new file mode 100644 index 000000000..fa9f45db4 --- /dev/null +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/network/nostr/NostrServiceTest.kt @@ -0,0 +1,76 @@ +package dev.dimension.flare.data.network.nostr + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import dev.dimension.flare.ui.model.UiAccount +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class NostrServiceTest { + @Test + fun generateAccountCreatesMatchingPrivateAndPublicKeys() { + val generated = NostrService.generateAccount(relayInput = "") + + assertMatchesKeyPair(generated) + assertEquals( + NostrService.defaultRelays.map { RelayUrlNormalizer.normalizeOrNull(it)!!.url }, + generated.relays, + ) + } + + @Test + fun exportAccountKeepsPrivateAndPublicKeysConsistent() { + val generated = NostrService.generateAccount(relayInput = "wss://relay.damus.io, wss://nos.lol") + val exported = + NostrService.exportAccount( + UiAccount.Nostr.Credential( + pubkey = generated.pubkeyHex, + nsec = generated.nsec, + relays = generated.relays, + ), + ) + + assertMatchesKeyPair(exported) + assertEquals(generated.pubkeyHex, exported.pubkeyHex) + assertEquals(generated.npub, exported.npub) + assertEquals(generated.relays, exported.relays) + } + + @Test + fun importAccountAcceptsSecretOnlyAndNormalizesRelays() { + val imported = + NostrService.importAccount( + publicKeyInput = "", + secretKeyInput = SECRET_KEY_HEX, + relayInput = "wss://relay.damus.io/ wss://relay.damus.io wss://nos.lol", + ) + + assertMatchesKeyPair(imported) + assertEquals( + listOf("wss://relay.damus.io/", "wss://nos.lol/"), + imported.relays, + ) + } + + private fun assertMatchesKeyPair(account: NostrService.ImportedAccount) { + assertEquals(64, account.pubkeyHex.length) + assertTrue(account.npub.startsWith("npub1")) + val normalizedSecret = assertNotNull(account.nsec) + assertTrue(normalizedSecret.isNotBlank()) + + val reImported = + NostrService.importAccount( + publicKeyInput = "", + secretKeyInput = normalizedSecret, + relayInput = account.relays.joinToString(","), + ) + assertEquals(account.pubkeyHex, reImported.pubkeyHex) + assertEquals(account.npub, NPub.create(account.pubkeyHex)) + } + + private companion object { + const val SECRET_KEY_HEX = "1111111111111111111111111111111111111111111111111111111111111111" + } +} diff --git a/shared/src/jvmTest/kotlin/dev/dimension/flare/data/network/nostr/NostrServiceJvmIntegrationTest.kt b/shared/src/jvmTest/kotlin/dev/dimension/flare/data/network/nostr/NostrServiceJvmIntegrationTest.kt new file mode 100644 index 000000000..3f0187632 --- /dev/null +++ b/shared/src/jvmTest/kotlin/dev/dimension/flare/data/network/nostr/NostrServiceJvmIntegrationTest.kt @@ -0,0 +1,146 @@ +package dev.dimension.flare.data.network.nostr + +import dev.dimension.flare.common.TestFormatter +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.humanizer.PlatformFormatter +import dev.dimension.flare.ui.model.UiAccount +import dev.dimension.flare.ui.model.UiTimelineV2 +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import java.security.SecureRandom +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class NostrServiceJvmIntegrationTest { + @BeforeTest + fun setUp() { + stopKoin() + startKoin { + modules( + module { + single { TestFormatter() } + }, + ) + } + } + + @AfterTest + fun tearDown() { + stopKoin() + } + + @Test + fun generatedAccountCanLoadProfileForKnownNpub() = + runBlocking { + val viewerAccount = createViewerAccount() + val credential = + UiAccount.Nostr.Credential( + pubkey = viewerAccount.pubkeyHex, + nsec = viewerAccount.nsec, + relays = viewerAccount.relays, + ) + + val profile = + NostrService.loadProfile( + credential = credential, + accountKey = TEST_ACCOUNT_KEY, + targetPubkey = TARGET_PUBKEY.pubkeyHex, + ) + + assertNotEquals(TARGET_PUBKEY.pubkeyHex, viewerAccount.pubkeyHex) + assertNotNull(viewerAccount.nsec) + assertEquals(MicroBlogKey(TARGET_PUBKEY.pubkeyHex, NostrService.NOSTR_HOST), profile.key) + assertEquals(PlatformType.Nostr, profile.platformType) + assertEquals(NostrService.NOSTR_HOST, profile.handle.host) + assertTrue( + profile.avatar.isNotBlank() || + profile.banner != null || + profile.description != null || + profile.name.raw != TARGET_NPUB.take(16), + "Expected profile metadata to be populated for $TARGET_NPUB, " + + "actual avatar=${profile.avatar}, banner=${profile.banner}, " + + "description=${profile.description?.raw}, name=${profile.name.raw}, handle=${profile.handle.raw}", + ) + } + + @Test + fun generatedAccountCanLoadTimelineForKnownNpub() = + runBlocking { + val viewerAccount = createViewerAccount() + val credential = + UiAccount.Nostr.Credential( + pubkey = viewerAccount.pubkeyHex, + nsec = viewerAccount.nsec, + relays = viewerAccount.relays, + ) + + val timeline = + loadTimelineWithRetry( + credential = credential, + targetPubkey = TARGET_PUBKEY.pubkeyHex, + ) + assertTrue(timeline.isNotEmpty(), "Expected timeline entries for $TARGET_NPUB") + val posts = timeline.filterIsInstance() + assertTrue(posts.isNotEmpty(), "Expected mapped posts for $TARGET_NPUB") + assertTrue(posts.all { it.platformType == PlatformType.Nostr }) + assertTrue(posts.any { it.content.raw.isNotBlank() }) + assertTrue( + posts.any { it.user?.key == MicroBlogKey(TARGET_PUBKEY.pubkeyHex, NostrService.NOSTR_HOST) }, + "Expected at least one post authored by $TARGET_NPUB", + ) + } + + private fun createViewerAccount(): NostrService.ImportedAccount = + NostrService.importAccount( + publicKeyInput = "", + secretKeyInput = generateSecretKeyHex(), + relayInput = "", + ) + + private suspend fun loadTimelineWithRetry( + credential: UiAccount.Nostr.Credential, + targetPubkey: String, + ): List { + repeat(TIMELINE_RETRY_COUNT) { attempt -> + val timeline = + NostrService.loadUserTimeline( + credential = credential, + accountKey = TEST_ACCOUNT_KEY, + targetPubkey = targetPubkey, + pageSize = 20, + until = null, + mediaOnly = false, + ) + if (timeline.isNotEmpty()) { + return timeline + } + if (attempt < TIMELINE_RETRY_COUNT - 1) { + delay(TIMELINE_RETRY_DELAY_MILLIS) + } + } + return emptyList() + } + + private companion object { + const val TARGET_NPUB = "npub1plstrz6dhu8q4fq0e4rjpxe2fxe5x87y2w6xpm70gh9qh5tt66kqkgkx8j" + const val TIMELINE_RETRY_COUNT = 3 + const val TIMELINE_RETRY_DELAY_MILLIS = 1_000L + val TEST_ACCOUNT_KEY = MicroBlogKey("nostr-integration", NostrService.NOSTR_HOST) + val TARGET_PUBKEY = NostrService.importAccount(TARGET_NPUB, "", "") + val SECURE_RANDOM = SecureRandom() + + fun generateSecretKeyHex(): String = + ByteArray(32) + .also(SECURE_RANDOM::nextBytes) + .joinToString(separator = "") { byte -> "%02x".format(byte) } + } +} From 0ac207966bec3bdf03a06313a31a140ea5fb8c34 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Mon, 23 Mar 2026 17:59:06 +0900 Subject: [PATCH 2/6] rework platform specs --- app/src/main/java/dev/dimension/flare/App.kt | 3 +- .../flare/ui/screen/home/GroupConfigScreen.kt | 4 +- .../flare/ui/screen/list/ListEntryBuilder.kt | 2 +- .../ui/screen/misskey/MisskeyEntryBuilder.kt | 4 +- .../flare/ui/screen/rss/RssEntryBuilder.kt | 2 +- .../data/model/LocalAppearanceSettings.kt | 9 + .../dev/dimension/flare/di/ComposeUiModule.kt | 15 - .../dimension/flare/ui/component/TabIcon.kt | 56 +- .../component/status/CommonStatusComponent.kt | 36 ++ .../dev/dimension/flare/ui/model/UiListExt.kt | 8 +- .../ui/screen/login/NostrInputPresenter.kt | 4 +- .../ui/screen/settings/EditTabPresenter.kt | 2 +- .../flare/ui/controllers/ComposeUIHelper.kt | 1 - .../main/kotlin/dev/dimension/flare/Main.kt | 3 +- .../dev/dimension/flare/ui/route/Router.kt | 8 +- .../flare/ui/screen/feeds/FeedScreen.kt | 2 +- .../flare/ui/screen/home/GroupConfigScreen.kt | 4 +- .../flare/ui/screen/list/ListScreen.kt | 2 +- .../Component/Status/StatusActionView.swift | 20 + iosApp/flare/UI/Component/TabIcon.swift | 29 +- .../flare/UI/Screen/GroupConfigScreen.swift | 2 +- .../flare/common/deeplink/DeepLinkMapping.kt | 108 ---- .../RecommendInstancePagingSource.kt | 14 +- .../flare/data/datastore/AppDataStore.kt | 10 +- .../flare/data/model/AppearanceSettings.kt | 12 +- .../dimension/flare/data/model/DataExport.kt | 0 .../flare/data/model/SettingsExport.kt | 0 .../dimension/flare/data/model/TabSettings.kt | 541 +++--------------- .../bluesky/BlueskyPlatformDetector.kt | 21 + .../mastodon/MastodonPlatformDetector.kt | 51 ++ .../misskey/MisskeyPlatformDetector.kt | 40 ++ .../data/network/nodeinfo/NodeInfoService.kt | 148 +---- .../data/network/nodeinfo/PlatformDetector.kt | 8 + .../network/nostr/NostrPlatformDetector.kt | 19 + .../flare/data/network/nostr/NostrService.kt | 22 +- .../data/network/vvo/VVOPlatformDetector.kt | 26 + .../data/network/xqt/XQTPlatformDetector.kt | 24 + .../data/platform/BlueskyPlatformSpec.kt | 82 +++ .../data/platform/MastodonPlatformSpec.kt | 95 +++ .../data/platform/MisskeyPlatformSpec.kt | 106 ++++ .../flare/data/platform/NostrPlatformSpec.kt | 41 ++ .../flare/data/platform/VvoPlatformSpec.kt | 70 +++ .../flare/data/platform/XqtPlatformSpec.kt | 111 ++++ .../data/repository/AccountRepository.kt | 15 +- .../data/repository/SettingsRepository.kt | 0 .../dev/dimension/flare/di/CommonModule.kt | 2 + .../dev/dimension/flare/model/PlatformSpec.kt | 53 ++ .../dev/dimension/flare/model/PlatformType.kt | 61 +- .../dev/dimension/flare/ui/model/UiIcon.kt | 26 +- .../dimension/flare/ui/model/UiRssSource.kt | 4 +- .../flare/ui/presenter/ExportDataPresenter.kt | 0 .../ui/presenter/ExportSettingsPresenter.kt | 0 .../flare/ui/presenter/HomeTabsPresenter.kt | 5 +- .../HomeTimelineWithTabsPresenter.kt | 7 +- .../flare/ui/presenter/ImportDataPresenter.kt | 0 .../ui/presenter/ImportSettingsPresenter.kt | 0 .../flare/ui/presenter/PinTabsPresenter.kt | 0 .../flare/ui/presenter/SettingsPresenter.kt | 0 .../ui/presenter/home/DeepLinkPresenter.kt | 8 +- .../login/InstanceMetadataPresenter.kt | 24 +- .../bluesky/BlueskyFeedsWithTabsPresenter.kt | 2 +- .../screen/list/AllListWithTabsPresenter.kt | 2 +- .../MisskeyAntennasListWithTabsPresenter.kt | 2 +- .../ui/screen/rss/RssListWithTabsPresenter.kt | 0 .../settings/AccountManagementPresenter.kt | 0 .../ui/screen/settings/AllTabsPresenter.kt | 5 +- .../common/deeplink/DeepLinkMappingTest.kt | 66 +-- 67 files changed, 1033 insertions(+), 1014 deletions(-) create mode 100644 compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/LocalAppearanceSettings.kt delete mode 100644 compose-ui/src/commonMain/kotlin/dev/dimension/flare/di/ComposeUiModule.kt rename {compose-ui => shared}/src/commonMain/kotlin/dev/dimension/flare/data/model/AppearanceSettings.kt (84%) rename {compose-ui => shared}/src/commonMain/kotlin/dev/dimension/flare/data/model/DataExport.kt (100%) rename {compose-ui => shared}/src/commonMain/kotlin/dev/dimension/flare/data/model/SettingsExport.kt (100%) rename {compose-ui => shared}/src/commonMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt (50%) create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/network/bluesky/BlueskyPlatformDetector.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/network/mastodon/MastodonPlatformDetector.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/MisskeyPlatformDetector.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nodeinfo/PlatformDetector.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrPlatformDetector.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/network/vvo/VVOPlatformDetector.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/XQTPlatformDetector.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/BlueskyPlatformSpec.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/MastodonPlatformSpec.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/MisskeyPlatformSpec.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/NostrPlatformSpec.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/VvoPlatformSpec.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/XqtPlatformSpec.kt rename {compose-ui => shared}/src/commonMain/kotlin/dev/dimension/flare/data/repository/SettingsRepository.kt (100%) create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformSpec.kt rename {compose-ui => shared}/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/ExportDataPresenter.kt (100%) rename {compose-ui => shared}/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/ExportSettingsPresenter.kt (100%) rename {compose-ui => shared}/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTabsPresenter.kt (96%) rename {compose-ui => shared}/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt (95%) rename {compose-ui => shared}/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/ImportDataPresenter.kt (100%) rename {compose-ui => shared}/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/ImportSettingsPresenter.kt (100%) rename {compose-ui => shared}/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/PinTabsPresenter.kt (100%) rename {compose-ui/src/iosMain => shared/src/commonMain}/kotlin/dev/dimension/flare/ui/presenter/SettingsPresenter.kt (100%) rename {compose-ui => shared}/src/commonMain/kotlin/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedsWithTabsPresenter.kt (96%) rename {compose-ui => shared}/src/commonMain/kotlin/dev/dimension/flare/ui/screen/list/AllListWithTabsPresenter.kt (96%) rename {compose-ui => shared}/src/commonMain/kotlin/dev/dimension/flare/ui/screen/misskey/MisskeyAntennasListWithTabsPresenter.kt (96%) rename {compose-ui => shared}/src/commonMain/kotlin/dev/dimension/flare/ui/screen/rss/RssListWithTabsPresenter.kt (100%) rename {compose-ui => shared}/src/commonMain/kotlin/dev/dimension/flare/ui/screen/settings/AccountManagementPresenter.kt (100%) rename {compose-ui => shared}/src/commonMain/kotlin/dev/dimension/flare/ui/screen/settings/AllTabsPresenter.kt (96%) diff --git a/app/src/main/java/dev/dimension/flare/App.kt b/app/src/main/java/dev/dimension/flare/App.kt index cd24da6ce..f1a918c23 100644 --- a/app/src/main/java/dev/dimension/flare/App.kt +++ b/app/src/main/java/dev/dimension/flare/App.kt @@ -18,7 +18,6 @@ import dev.dimension.flare.data.network.ktorClient import dev.dimension.flare.di.KoinHelper import dev.dimension.flare.di.aiModule import dev.dimension.flare.di.androidModule -import dev.dimension.flare.di.composeUiModule import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin @@ -29,7 +28,7 @@ class App : super.onCreate() startKoin { androidContext(this@App) - modules(KoinHelper.modules() + androidModule + composeUiModule + aiModule) + modules(KoinHelper.modules() + androidModule + aiModule) } } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/GroupConfigScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/GroupConfigScreen.kt index 145ffcef0..454f04270 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/GroupConfigScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/GroupConfigScreen.kt @@ -269,7 +269,7 @@ private fun GroupConfigPresenter( ) var icon by remember { - mutableStateOf(initialItem?.metaData?.icon ?: IconType.Material(IconType.Material.MaterialIcon.Rss)) + mutableStateOf(initialItem?.metaData?.icon ?: IconType.Material(dev.dimension.flare.ui.model.UiIcon.Rss)) } val tabs = @@ -291,7 +291,7 @@ private fun GroupConfigPresenter( val showIconPicker = showIconPicker val allTabs = allTabs val availableIcons = - IconType.Material.MaterialIcon.entries + dev.dimension.flare.ui.model.UiIcon.entries .map { IconType.Material(it) } fun setIcon(newIcon: IconType) { diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/list/ListEntryBuilder.kt b/app/src/main/java/dev/dimension/flare/ui/screen/list/ListEntryBuilder.kt index a5531feb2..cee3c5889 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/list/ListEntryBuilder.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/list/ListEntryBuilder.kt @@ -62,7 +62,7 @@ internal fun EntryProviderScope.listEntryBuilder( listId = args.listId, metaData = TabMetaData( title = TitleType.Text(args.title), - icon = IconType.Material(IconType.Material.MaterialIcon.List), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.List), ), ) }, diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/misskey/MisskeyEntryBuilder.kt b/app/src/main/java/dev/dimension/flare/ui/screen/misskey/MisskeyEntryBuilder.kt index c5df70cea..3adfeaad1 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/misskey/MisskeyEntryBuilder.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/misskey/MisskeyEntryBuilder.kt @@ -62,7 +62,7 @@ internal fun EntryProviderScope.misskeyEntryBuilder( antennasId = args.antennaId, metaData = TabMetaData( title = TitleType.Text(args.title), - icon = IconType.Material(IconType.Material.MaterialIcon.Rss), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.Rss), ), ) }, @@ -99,7 +99,7 @@ internal fun EntryProviderScope.misskeyEntryBuilder( account = args.accountType, metaData = TabMetaData( title = TitleType.Text(args.title), - icon = IconType.Material(IconType.Material.MaterialIcon.List), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.List), ), ) }, diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/rss/RssEntryBuilder.kt b/app/src/main/java/dev/dimension/flare/ui/screen/rss/RssEntryBuilder.kt index 62db12588..e752b36ce 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/rss/RssEntryBuilder.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/rss/RssEntryBuilder.kt @@ -78,7 +78,7 @@ internal fun EntryProviderScope.rssEntryBuilder( title = TitleType.Text(args.title ?: args.url), icon = args.favIcon?.let { IconType.Url(args.favIcon) - } ?: IconType.Material(IconType.Material.MaterialIcon.Rss) + } ?: IconType.Material(dev.dimension.flare.ui.model.UiIcon.Rss) ) ) }, diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/LocalAppearanceSettings.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/LocalAppearanceSettings.kt new file mode 100644 index 000000000..9c6f6b158 --- /dev/null +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/LocalAppearanceSettings.kt @@ -0,0 +1,9 @@ +package dev.dimension.flare.data.model + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf +import kotlin.native.HiddenFromObjC + +@HiddenFromObjC +public val LocalAppearanceSettings: ProvidableCompositionLocal = + staticCompositionLocalOf { AppearanceSettings() } diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/di/ComposeUiModule.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/di/ComposeUiModule.kt deleted file mode 100644 index acbe5975d..000000000 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/di/ComposeUiModule.kt +++ /dev/null @@ -1,15 +0,0 @@ -package dev.dimension.flare.di - -import dev.dimension.flare.data.repository.SettingsRepository -import org.koin.core.module.Module -import org.koin.core.module.dsl.singleOf -import org.koin.dsl.module -import kotlin.experimental.ExperimentalObjCRefinement -import kotlin.native.HiddenFromObjC - -@OptIn(ExperimentalObjCRefinement::class) -@HiddenFromObjC -public val composeUiModule: Module = - module { - singleOf(::SettingsRepository) - } 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 bc7286be4..ca7f368f1 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 @@ -11,33 +11,11 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp -import compose.icons.FontAwesomeIcons -import compose.icons.fontawesomeicons.Brands -import compose.icons.fontawesomeicons.Solid -import compose.icons.fontawesomeicons.brands.Bluesky -import compose.icons.fontawesomeicons.brands.Mastodon -import compose.icons.fontawesomeicons.brands.Weibo -import compose.icons.fontawesomeicons.brands.XTwitter -import compose.icons.fontawesomeicons.solid.Bell -import compose.icons.fontawesomeicons.solid.BookBookmark -import compose.icons.fontawesomeicons.solid.CircleUser -import compose.icons.fontawesomeicons.solid.Gear -import compose.icons.fontawesomeicons.solid.Globe -import compose.icons.fontawesomeicons.solid.Heart -import compose.icons.fontawesomeicons.solid.House -import compose.icons.fontawesomeicons.solid.List -import compose.icons.fontawesomeicons.solid.MagnifyingGlass -import compose.icons.fontawesomeicons.solid.Message -import compose.icons.fontawesomeicons.solid.RectangleList -import compose.icons.fontawesomeicons.solid.SquareRss -import compose.icons.fontawesomeicons.solid.Tv -import compose.icons.fontawesomeicons.solid.Users import dev.dimension.flare.compose.ui.Res import dev.dimension.flare.compose.ui.all_rss_feeds_title import dev.dimension.flare.compose.ui.antenna_title @@ -65,8 +43,8 @@ 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.platform.PlatformText +import dev.dimension.flare.ui.component.status.toImageVector import dev.dimension.flare.ui.component.platform.PlatformTextStyle -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 @@ -151,7 +129,7 @@ public fun TabIcon( is IconType.Material -> { FAIcon( - imageVector = icon.icon.toIcon(), + imageVector = icon.icon.toImageVector(), contentDescription = when (title) { is TitleType.Localized -> stringResource(title.res) @@ -167,7 +145,7 @@ public fun TabIcon( is IconType.Mixed -> { if (iconOnly) { FAIcon( - imageVector = icon.icon.toIcon(), + imageVector = icon.icon.toImageVector(), contentDescription = when (title) { is TitleType.Localized -> stringResource(title.res) @@ -201,7 +179,7 @@ public fun TabIcon( ) } FAIcon( - imageVector = icon.icon.toIcon(), + imageVector = icon.icon.toImageVector(), contentDescription = when (title) { is TitleType.Localized -> stringResource(title.res) @@ -290,28 +268,4 @@ internal val TitleType.Localized.res: StringResource TitleType.Localized.LocalizedKey.AllRssFeeds -> Res.string.all_rss_feeds_title TitleType.Localized.LocalizedKey.Posts -> Res.string.posts_title TitleType.Localized.LocalizedKey.Channel -> Res.string.channel_title - } - -internal fun IconType.Material.MaterialIcon.toIcon(): ImageVector = - when (this) { - IconType.Material.MaterialIcon.Home -> FontAwesomeIcons.Solid.House - IconType.Material.MaterialIcon.Notification -> FontAwesomeIcons.Solid.Bell - IconType.Material.MaterialIcon.Search -> FontAwesomeIcons.Solid.MagnifyingGlass - IconType.Material.MaterialIcon.Profile -> FontAwesomeIcons.Solid.CircleUser - IconType.Material.MaterialIcon.Settings -> FontAwesomeIcons.Solid.Gear - IconType.Material.MaterialIcon.Local -> FontAwesomeIcons.Solid.Users - IconType.Material.MaterialIcon.World -> FontAwesomeIcons.Solid.Globe - IconType.Material.MaterialIcon.Featured -> FontAwesomeIcons.Solid.RectangleList - IconType.Material.MaterialIcon.Bookmark -> FontAwesomeIcons.Solid.BookBookmark - IconType.Material.MaterialIcon.Heart -> FontAwesomeIcons.Solid.Heart - IconType.Material.MaterialIcon.Twitter -> FontAwesomeIcons.Brands.XTwitter - IconType.Material.MaterialIcon.Mastodon -> FontAwesomeIcons.Brands.Mastodon - IconType.Material.MaterialIcon.Misskey -> FontAwesomeIcons.Brands.Misskey - IconType.Material.MaterialIcon.Bluesky -> FontAwesomeIcons.Brands.Bluesky - IconType.Material.MaterialIcon.List -> FontAwesomeIcons.Solid.List - IconType.Material.MaterialIcon.Feeds -> FontAwesomeIcons.Solid.SquareRss - IconType.Material.MaterialIcon.Messages -> FontAwesomeIcons.Solid.Message - IconType.Material.MaterialIcon.Rss -> FontAwesomeIcons.Solid.SquareRss - IconType.Material.MaterialIcon.Weibo -> FontAwesomeIcons.Brands.Weibo - IconType.Material.MaterialIcon.Channel -> FontAwesomeIcons.Solid.Tv - } +} diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt index 3418bcf90..3d51c9fad 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt @@ -46,36 +46,50 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Brands import compose.icons.fontawesomeicons.Regular import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.brands.Bluesky +import compose.icons.fontawesomeicons.brands.Mastodon +import compose.icons.fontawesomeicons.brands.Weibo +import compose.icons.fontawesomeicons.brands.XTwitter import compose.icons.fontawesomeicons.regular.Bookmark import compose.icons.fontawesomeicons.regular.CommentDots import compose.icons.fontawesomeicons.regular.Heart import compose.icons.fontawesomeicons.solid.At +import compose.icons.fontawesomeicons.solid.Bell +import compose.icons.fontawesomeicons.solid.BookBookmark import compose.icons.fontawesomeicons.solid.Bookmark import compose.icons.fontawesomeicons.solid.Check import compose.icons.fontawesomeicons.solid.CircleInfo +import compose.icons.fontawesomeicons.solid.CircleUser import compose.icons.fontawesomeicons.solid.Ellipsis import compose.icons.fontawesomeicons.solid.EllipsisVertical +import compose.icons.fontawesomeicons.solid.Gear import compose.icons.fontawesomeicons.solid.Globe import compose.icons.fontawesomeicons.solid.Heart +import compose.icons.fontawesomeicons.solid.House import compose.icons.fontawesomeicons.solid.Image import compose.icons.fontawesomeicons.solid.List import compose.icons.fontawesomeicons.solid.Lock import compose.icons.fontawesomeicons.solid.LockOpen +import compose.icons.fontawesomeicons.solid.MagnifyingGlass import compose.icons.fontawesomeicons.solid.Message import compose.icons.fontawesomeicons.solid.Minus import compose.icons.fontawesomeicons.solid.Pen import compose.icons.fontawesomeicons.solid.Plus +import compose.icons.fontawesomeicons.solid.RectangleList import compose.icons.fontawesomeicons.solid.Reply import compose.icons.fontawesomeicons.solid.Retweet import compose.icons.fontawesomeicons.solid.ShareNodes +import compose.icons.fontawesomeicons.solid.SquareRss import compose.icons.fontawesomeicons.solid.SquarePollHorizontal import compose.icons.fontawesomeicons.solid.Thumbtack import compose.icons.fontawesomeicons.solid.Trash import compose.icons.fontawesomeicons.solid.Tv import compose.icons.fontawesomeicons.solid.UserPlus import compose.icons.fontawesomeicons.solid.UserSlash +import compose.icons.fontawesomeicons.solid.Users import compose.icons.fontawesomeicons.solid.VolumeXmark import dev.dimension.flare.compose.ui.Res import dev.dimension.flare.compose.ui.bookmark_add @@ -140,6 +154,8 @@ import dev.dimension.flare.ui.component.platform.PlatformRadioButton import dev.dimension.flare.ui.component.platform.PlatformText import dev.dimension.flare.ui.component.platform.PlatformTextButton import dev.dimension.flare.ui.component.platform.PlatformTextStyle +import dev.dimension.flare.ui.icons.Misskey +import dev.dimension.flare.ui.icons.Nostr import dev.dimension.flare.ui.model.ClickContext import dev.dimension.flare.ui.model.UiCard import dev.dimension.flare.ui.model.UiIcon @@ -996,6 +1012,26 @@ internal fun UiIcon.toImageVector(): ImageVector = UiIcon.Info -> FontAwesomeIcons.Solid.CircleInfo UiIcon.Pin -> FontAwesomeIcons.Solid.Thumbtack UiIcon.Check -> FontAwesomeIcons.Solid.Check + UiIcon.Home -> FontAwesomeIcons.Solid.House + UiIcon.Notification -> FontAwesomeIcons.Solid.Bell + UiIcon.Search -> FontAwesomeIcons.Solid.MagnifyingGlass + UiIcon.Profile -> FontAwesomeIcons.Solid.CircleUser + UiIcon.Settings -> FontAwesomeIcons.Solid.Gear + UiIcon.Local -> FontAwesomeIcons.Solid.Users + UiIcon.World -> FontAwesomeIcons.Solid.Globe + UiIcon.Featured -> FontAwesomeIcons.Solid.RectangleList + UiIcon.Feeds -> FontAwesomeIcons.Solid.SquareRss + UiIcon.Messages -> FontAwesomeIcons.Solid.Message + UiIcon.Rss -> FontAwesomeIcons.Solid.SquareRss + UiIcon.Channel -> FontAwesomeIcons.Solid.Tv + UiIcon.Heart -> FontAwesomeIcons.Solid.Heart + UiIcon.Mastodon -> FontAwesomeIcons.Brands.Mastodon + UiIcon.Misskey -> FontAwesomeIcons.Brands.Misskey + UiIcon.Bluesky -> FontAwesomeIcons.Brands.Bluesky + UiIcon.Nostr -> FontAwesomeIcons.Brands.Nostr + UiIcon.Twitter -> FontAwesomeIcons.Brands.XTwitter + UiIcon.X -> FontAwesomeIcons.Brands.XTwitter + UiIcon.Weibo -> FontAwesomeIcons.Brands.Weibo } @Composable diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiListExt.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiListExt.kt index 67393f621..de1a20faf 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiListExt.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiListExt.kt @@ -22,7 +22,7 @@ public fun UiList.toTabItem(accountKey: MicroBlogKey): TabItem = icon = avatar?.let { IconType.Url(it) - } ?: IconType.Material(IconType.Material.MaterialIcon.List), + } ?: IconType.Material(dev.dimension.flare.ui.model.UiIcon.List), ), ) @@ -36,7 +36,7 @@ public fun UiList.toTabItem(accountKey: MicroBlogKey): TabItem = icon = avatar?.let { IconType.Url(it) - } ?: IconType.Material(IconType.Material.MaterialIcon.Feeds), + } ?: IconType.Material(dev.dimension.flare.ui.model.UiIcon.Feeds), ), ) @@ -47,7 +47,7 @@ public fun UiList.toTabItem(accountKey: MicroBlogKey): TabItem = metaData = TabMetaData( title = TitleType.Text(title), - icon = IconType.Material(IconType.Material.MaterialIcon.List), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.List), ), ) @@ -58,7 +58,7 @@ public fun UiList.toTabItem(accountKey: MicroBlogKey): TabItem = metaData = TabMetaData( title = TitleType.Text(title), - icon = IconType.Material(IconType.Material.MaterialIcon.List), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.List), ), ) } diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/NostrInputPresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/NostrInputPresenter.kt index 654864c44..9f28c8f3a 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/NostrInputPresenter.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/NostrInputPresenter.kt @@ -7,7 +7,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import dev.dimension.flare.data.network.nostr.NostrService +import dev.dimension.flare.data.network.nostr.defaultNostrRelays import dev.dimension.flare.ui.presenter.PresenterBase import kotlin.native.HiddenFromObjC @@ -27,7 +27,7 @@ public class NostrInputPresenter : PresenterBase() { val secretKey = rememberTextFieldState() val relays = rememberTextFieldState( - initialText = NostrService.defaultRelays.joinToString(", "), + initialText = defaultNostrRelays.joinToString(", "), ) val canLogin by remember(publicKey, secretKey) { 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 68711bf35..7a91983a9 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 @@ -65,7 +65,7 @@ public class EditTabPresenter( else -> emptyList() } + - IconType.Material.MaterialIcon.entries.map { + dev.dimension.flare.ui.model.UiIcon.entries.map { IconType.Material(it) } + if (tabItem is RssTimelineTabItem) { diff --git a/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/controllers/ComposeUIHelper.kt b/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/controllers/ComposeUIHelper.kt index e4340dcf1..dab956922 100644 --- a/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/controllers/ComposeUIHelper.kt +++ b/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/controllers/ComposeUIHelper.kt @@ -46,7 +46,6 @@ public object ComposeUIHelper { } bind SwiftPlatformTextRenderer::class }, ) - modules(dev.dimension.flare.di.composeUiModule) } SingletonImageLoader.setSafe { context -> ImageLoader diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt index 4bfe10bd5..85d13bdbe 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt @@ -22,7 +22,6 @@ import coil3.request.crossfade import dev.dimension.flare.common.DeeplinkHandler import dev.dimension.flare.data.network.ktorClient import dev.dimension.flare.di.KoinHelper -import dev.dimension.flare.di.composeUiModule import dev.dimension.flare.di.desktopModule import dev.dimension.flare.ui.component.PlatformTitleBar import dev.dimension.flare.ui.component.PlatformWindow @@ -70,7 +69,7 @@ fun main(args: Array) { } startKoin { modules( - desktopModule + KoinHelper.modules() + composeUiModule, + desktopModule + KoinHelper.modules(), ) } application { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt index 8a1e5f0a3..e5370a3e4 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt @@ -425,7 +425,7 @@ internal fun WindowScope.Router( metaData = TabMetaData( title = TitleType.Text(it.title), - icon = Material(Material.MaterialIcon.List), + icon = Material(dev.dimension.flare.ui.model.UiIcon.List), ), ), ), @@ -450,7 +450,7 @@ internal fun WindowScope.Router( metaData = TabMetaData( title = TitleType.Text(it.title), - icon = Material(Material.MaterialIcon.Feeds), + icon = Material(dev.dimension.flare.ui.model.UiIcon.Feeds), ), ), ), @@ -857,7 +857,7 @@ internal fun WindowScope.Router( metaData = TabMetaData( title = TitleType.Text(it.title), - icon = IconType.Material(IconType.Material.MaterialIcon.Rss), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.Rss), ), ), ), @@ -989,7 +989,7 @@ internal fun WindowScope.Router( metaData = TabMetaData( title = TitleType.Text(args.title), - icon = IconType.Material(IconType.Material.MaterialIcon.Channel), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.Channel), ), ) }, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedScreen.kt index c1c2b3668..141844fb4 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedScreen.kt @@ -47,7 +47,7 @@ internal fun FeedScreen(accountType: AccountType) { metaData = TabMetaData( title = TitleType.Text(it.title), - icon = IconType.Material(IconType.Material.MaterialIcon.Feeds), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.Feeds), ), ) }, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/GroupConfigScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/GroupConfigScreen.kt index d3654ce05..2e5b3d529 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/GroupConfigScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/GroupConfigScreen.kt @@ -286,7 +286,7 @@ private fun GroupConfigPresenter( var icon by remember { mutableStateOf( - initialItem?.metaData?.icon ?: IconType.Material(IconType.Material.MaterialIcon.Rss), + initialItem?.metaData?.icon ?: IconType.Material(dev.dimension.flare.ui.model.UiIcon.Rss), ) } @@ -309,7 +309,7 @@ private fun GroupConfigPresenter( val showIconPicker = showIconPicker val allTabs = allTabs val availableIcons = - IconType.Material.MaterialIcon.entries + dev.dimension.flare.ui.model.UiIcon.entries .map { IconType.Material(it) } fun setIcon(newIcon: IconType) { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/ListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/ListScreen.kt index 9323bf73c..6e66f51f3 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/ListScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/ListScreen.kt @@ -49,7 +49,7 @@ internal fun ListScreen(accountType: AccountType) { metaData = TabMetaData( title = TitleType.Text(it.title), - icon = IconType.Material(IconType.Material.MaterialIcon.List), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.List), ), ) }, diff --git a/iosApp/flare/UI/Component/Status/StatusActionView.swift b/iosApp/flare/UI/Component/Status/StatusActionView.swift index f196da40a..fc547b053 100644 --- a/iosApp/flare/UI/Component/Status/StatusActionView.swift +++ b/iosApp/flare/UI/Component/Status/StatusActionView.swift @@ -244,6 +244,14 @@ extension UiIcon { var imageName: String { switch self { + case .home: return "fa-house" + case .notification: return "fa-bell" + case .search: return "fa-magnifying-glass" + case .profile: return "fa-circle-user" + case .settings: return "fa-gear" + case .local: return "fa-users" + case .world: return "fa-globe" + case .featured: return "fa-rectangle-list" case .bookmark: return "fa-bookmark" case .unbookmark: return "fa-bookmark.fill" case .delete: return "fa-trash" @@ -274,6 +282,18 @@ extension UiIcon { case .info: return "fa-circle-info" case .pin: return "fa-thumbtack" case .check: return "fa-check" + case .feeds: return "fa-square-rss" + case .messages: return "fa-message" + case .rss: return "fa-square-rss" + case .channel: return "fa-tv" + case .heart: return "fa-heart" + case .mastodon: return "fa-mastodon" + case .misskey: return "fa-misskey" + case .bluesky: return "fa-bluesky" + case .nostr: return "fa-circle-question" + case .twitter: return "fa-x-twitter" + case .x: return "fa-x-twitter" + case .weibo: return "fa-weibo" } } } diff --git a/iosApp/flare/UI/Component/TabIcon.swift b/iosApp/flare/UI/Component/TabIcon.swift index 9cc6aa331..2e5114567 100644 --- a/iosApp/flare/UI/Component/TabIcon.swift +++ b/iosApp/flare/UI/Component/TabIcon.swift @@ -108,7 +108,7 @@ extension TabIcon { } struct MaterialTabIcon: View { - let icon: IconType.MaterialMaterialIcon + let icon: UiIcon var body: some View { Image(icon.imageName) .resizable() @@ -116,33 +116,6 @@ struct MaterialTabIcon: View { } } -extension IconType.MaterialMaterialIcon { - var imageName: String { - switch self { - case .home: "fa-house" - case .notification: "fa-bell" - case .search: "fa-magnifying-glass" - case .profile: "fa-circle-user" - case .settings: "fa-gear" - case .local: "fa-users" - case .world: "fa-globe" - case .featured: "fa-rectangle-list" - case .bookmark: "fa-book-bookmark" - case .heart: "fa-heart" - case .twitter: "fa-x-twitter" - case .mastodon: "fa-mastodon" - case .misskey: "fa-misskey" - case .bluesky: "fa-bluesky" - case .list: "fa-list" - case .feeds: "fa-square-rss" - case .messages: "fa-message" - case .rss: "fa-square-rss" - case .weibo: "fa-weibo" - case .channel: "fa-tv" - } - } -} - struct AvatarTabIcon: View { @StateObject private var presenter: KotlinPresenter diff --git a/iosApp/flare/UI/Screen/GroupConfigScreen.swift b/iosApp/flare/UI/Screen/GroupConfigScreen.swift index 4037b21fa..90c8b06d2 100644 --- a/iosApp/flare/UI/Screen/GroupConfigScreen.swift +++ b/iosApp/flare/UI/Screen/GroupConfigScreen.swift @@ -179,7 +179,7 @@ struct IconPicker: View { @Environment(\.dismiss) private var dismiss @Binding var selectedIcon: IconType - let availableIcons: [IconType] = IconType.MaterialMaterialIcon.allCases.map { IconType.Material(icon: $0) } + let availableIcons: [IconType] = UiIcon.allCases.map { IconType.Material(icon: $0) } var body: some View { ScrollView { diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/common/deeplink/DeepLinkMapping.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/common/deeplink/DeepLinkMapping.kt index 72eb75878..e3bf1077d 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/common/deeplink/DeepLinkMapping.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/common/deeplink/DeepLinkMapping.kt @@ -2,9 +2,6 @@ package dev.dimension.flare.common.deeplink import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.model.PlatformType -import dev.dimension.flare.model.xqtHost -import dev.dimension.flare.model.xqtOldHost import dev.dimension.flare.ui.model.UiAccount import dev.dimension.flare.ui.route.DeeplinkRoute import io.ktor.http.Url @@ -83,111 +80,6 @@ internal object DeepLinkMapping { } } - fun generatePattern( - platformType: PlatformType, - host: String, - ): List> = - when (platformType) { - PlatformType.Nostr -> emptyList() - - PlatformType.Mastodon -> { - listOf( - DeepLinkPattern( - Type.Profile.serializer(), - Url("https://$host/@{handle}"), - ), - DeepLinkPattern( - Type.Post.serializer(), - Url("https://$host/@{handle}/{id}"), - ), - ) - } - - PlatformType.Misskey -> { - listOf( - DeepLinkPattern( - Type.Profile.serializer(), - Url("https://$host/@{handle}"), - ), - DeepLinkPattern( - Type.Post.serializer(), - Url("https://$host/notes/{id}"), - ), - ) - } - - PlatformType.Bluesky -> { - listOf( - DeepLinkPattern( - Type.Profile.serializer(), - Url("https://$host/profile/{handle}"), - ), - DeepLinkPattern( - Type.BlueskyPost.serializer(), - Url("https://$host/profile/{handle}/post/{id}"), - ), - ) + - if (host == "bsky.social") { - listOf( - DeepLinkPattern( - Type.Profile.serializer(), - Url("https://bsky.app/profile/{handle}"), - ), - DeepLinkPattern( - Type.BlueskyPost.serializer(), - Url("https://bsky.app/profile/{handle}/post/{id}"), - ), - ) - } else { - emptyList() - } - } - - PlatformType.xQt -> { - val profile = - listOf( - "https://$xqtHost/{handle}", - "https://$xqtOldHost/{handle}", - "https://www.$xqtHost/{handle}", - "https://www.$xqtOldHost/{handle}", - ) - val post = - listOf( - "https://$xqtHost/{handle}/status/{id}", - "https://$xqtOldHost/{handle}/", - "https://www.$xqtHost/{handle}/status/{id}", - "https://www.$xqtOldHost/{handle}/", - ) - val media = - listOf( - "https://$xqtHost/{handle}/status/{id}/photo/{index}", - "https://$xqtOldHost/{handle}/status/{id}/photo/{index}", - "https://www.$xqtHost/{handle}/status/{id}/photo/{index}", - "https://www.$xqtOldHost/{handle}/status/{id}/photo/{index}", - ) - profile.map { - DeepLinkPattern( - Type.Profile.serializer(), - Url(it), - ) - } + - post.map { - DeepLinkPattern( - Type.Post.serializer(), - Url(it), - ) - } + - media.map { - DeepLinkPattern( - Type.PostMedia.serializer(), - Url(it), - ) - } - } - - PlatformType.VVo -> emptyList() - } - fun matches( url: String, mapping: ImmutableMap>>, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/RecommendInstancePagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/RecommendInstancePagingSource.kt index c45a0b2e5..21e83b0b0 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/RecommendInstancePagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/RecommendInstancePagingSource.kt @@ -7,7 +7,7 @@ import dev.dimension.flare.data.network.mastodon.MastodonInstanceService import dev.dimension.flare.data.network.misskey.JoinMisskeyService import dev.dimension.flare.data.repository.tryRun import dev.dimension.flare.model.PlatformType -import dev.dimension.flare.model.logoUrl +import dev.dimension.flare.model.spec import dev.dimension.flare.ui.model.UiInstance import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -63,12 +63,12 @@ internal class RecommendInstancePagingSource : BasePagingSource description = it.title, iconUrl = it.thumbnail?.url - ?: PlatformType.Mastodon.logoUrl, + ?: PlatformType.Mastodon.spec.metadata.logoUrl, domain = it.domain ?: "pawoo.net", type = PlatformType.Mastodon, bannerUrl = it.thumbnail?.url - ?: PlatformType.Mastodon.logoUrl, + ?: PlatformType.Mastodon.spec.metadata.logoUrl, usersCount = it.usage?.users?.activeMonth ?: 0, ), ) @@ -82,7 +82,7 @@ internal class RecommendInstancePagingSource : BasePagingSource UiInstance( name = "mstdn.jp", description = "mstdn.jp", - iconUrl = PlatformType.Mastodon.logoUrl, + iconUrl = PlatformType.Mastodon.spec.metadata.logoUrl, domain = "mstdn.jp", type = PlatformType.Mastodon, bannerUrl = null, @@ -94,7 +94,7 @@ internal class RecommendInstancePagingSource : BasePagingSource UiInstance( name = "pawoo.net", description = "pawoo.net", - iconUrl = PlatformType.Mastodon.logoUrl, + iconUrl = PlatformType.Mastodon.spec.metadata.logoUrl, domain = "pawoo.net", type = PlatformType.Mastodon, bannerUrl = null, @@ -110,7 +110,7 @@ internal class RecommendInstancePagingSource : BasePagingSource description = "From breaking news and entertainment to sports and politics," + " get the full story with all the live commentary.", - iconUrl = PlatformType.xQt.logoUrl, + iconUrl = PlatformType.xQt.spec.metadata.logoUrl, domain = "x.com", type = PlatformType.xQt, bannerUrl = null, @@ -122,7 +122,7 @@ internal class RecommendInstancePagingSource : BasePagingSource "The web. Email. RSS feeds. XMPP chats. " + "What all these technologies had in common is they allowed people to freely interact " + "and create content, without a single intermediary.", - iconUrl = PlatformType.Bluesky.logoUrl, + iconUrl = PlatformType.Bluesky.spec.metadata.logoUrl, domain = "bsky.social", type = PlatformType.Bluesky, bannerUrl = null, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/AppDataStore.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/AppDataStore.kt index b4bfb73fa..733fe1fb3 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/AppDataStore.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/AppDataStore.kt @@ -15,10 +15,10 @@ import dev.dimension.flare.data.io.PlatformPathProducer import okio.FileSystem import okio.SYSTEM -public class AppDataStore( +internal class AppDataStore( private val platformPathProducer: PlatformPathProducer, ) { - internal val guestDataStore: DataStore by lazy { + val guestDataStore: DataStore by lazy { DataStoreFactory.create( storage = OkioStorage( @@ -31,7 +31,7 @@ public class AppDataStore( ) } - internal val flareDataStore: DataStore by lazy { + val flareDataStore: DataStore by lazy { DataStoreFactory.create( storage = OkioStorage( @@ -44,7 +44,7 @@ public class AppDataStore( ) } - internal val composeConfigData: DataStore by lazy { + val composeConfigData: DataStore by lazy { DataStoreFactory.create( storage = OkioStorage( @@ -57,7 +57,7 @@ public class AppDataStore( ) } - public val appSettingsStore: DataStore by lazy { + val appSettingsStore: DataStore by lazy { DataStoreFactory.create( storage = OkioStorage( diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/AppearanceSettings.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/AppearanceSettings.kt similarity index 84% rename from compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/AppearanceSettings.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/data/model/AppearanceSettings.kt index c3d9aabd0..aff084f85 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/AppearanceSettings.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/AppearanceSettings.kt @@ -1,8 +1,5 @@ package dev.dimension.flare.data.model -import androidx.compose.runtime.ProvidableCompositionLocal -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.graphics.Color import androidx.datastore.core.okio.OkioSerializer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -14,17 +11,12 @@ import kotlinx.serialization.encodeToByteArray import kotlinx.serialization.protobuf.ProtoBuf import okio.BufferedSink import okio.BufferedSource -import kotlin.native.HiddenFromObjC - -@HiddenFromObjC -public val LocalAppearanceSettings: ProvidableCompositionLocal = - staticCompositionLocalOf { AppearanceSettings() } @Serializable public data class AppearanceSettings( val theme: Theme = Theme.SYSTEM, val dynamicTheme: Boolean = true, - val colorSeed: ULong = Color(red = 103, green = 80, blue = 164).value, + val colorSeed: ULong = 4284960932u, val avatarShape: AvatarShape = AvatarShape.CIRCLE, @Deprecated( "Use postActionStyle instead", @@ -96,7 +88,7 @@ public enum class VideoAutoplay { } @OptIn(ExperimentalSerializationApi::class) -public object AccountPreferencesSerializer : OkioSerializer { +internal object AccountPreferencesSerializer : OkioSerializer { override val defaultValue: AppearanceSettings get() = AppearanceSettings() diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/DataExport.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/DataExport.kt similarity index 100% rename from compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/DataExport.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/data/model/DataExport.kt diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/SettingsExport.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/SettingsExport.kt similarity index 100% rename from compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/SettingsExport.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/data/model/SettingsExport.kt diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt similarity index 50% rename from compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt index 1bd2e933d..68dd8d9a9 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt @@ -5,17 +5,36 @@ import androidx.datastore.core.okio.OkioSerializer import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType -import dev.dimension.flare.ui.model.UiAccount +import dev.dimension.flare.model.spec +import dev.dimension.flare.ui.model.UiIcon import dev.dimension.flare.ui.model.UiList -import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiRssSource +import dev.dimension.flare.ui.presenter.home.DiscoverStatusTimelinePresenter import dev.dimension.flare.ui.presenter.home.HomeTimelinePresenter import dev.dimension.flare.ui.presenter.home.MixedTimelinePresenter import dev.dimension.flare.ui.presenter.home.TimelinePresenter +import dev.dimension.flare.ui.presenter.home.bluesky.BlueskyBookmarkTimelinePresenter +import dev.dimension.flare.ui.presenter.home.bluesky.BlueskyFeedTimelinePresenter +import dev.dimension.flare.ui.presenter.home.mastodon.MastodonBookmarkTimelinePresenter +import dev.dimension.flare.ui.presenter.home.mastodon.MastodonFavouriteTimelinePresenter +import dev.dimension.flare.ui.presenter.home.mastodon.MastodonLocalTimelinePresenter +import dev.dimension.flare.ui.presenter.home.mastodon.MastodonPublicTimelinePresenter +import dev.dimension.flare.ui.presenter.home.misskey.MissKeyLocalTimelinePresenter +import dev.dimension.flare.ui.presenter.home.misskey.MissKeyPublicTimelinePresenter +import dev.dimension.flare.ui.presenter.home.misskey.MisskeyFavouriteTimelinePresenter +import dev.dimension.flare.ui.presenter.home.misskey.MisskeyHybridTimelinePresenter +import dev.dimension.flare.ui.presenter.home.rss.AllRssTimelinePresenter +import dev.dimension.flare.ui.presenter.home.rss.RssTimelinePresenter +import dev.dimension.flare.ui.presenter.home.vvo.VVOFavouriteTimelinePresenter +import dev.dimension.flare.ui.presenter.home.vvo.VVOLikeTimelinePresenter +import dev.dimension.flare.ui.presenter.home.xqt.XQTBookmarkTimelinePresenter +import dev.dimension.flare.ui.presenter.home.xqt.XQTDeviceFollowTimelinePresenter +import dev.dimension.flare.ui.presenter.home.xqt.XQTFeaturedTimelinePresenter +import dev.dimension.flare.ui.presenter.list.AntennasTimelinePresenter +import dev.dimension.flare.ui.presenter.list.ChannelTimelinePresenter import dev.dimension.flare.ui.presenter.list.ListTimelinePresenter import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.withContext @@ -120,37 +139,13 @@ public sealed class IconType { @Immutable @Serializable public data class Material( - val icon: MaterialIcon, - ) : IconType() { - @Serializable - public enum class MaterialIcon { - Home, - Notification, - Search, - Profile, - Settings, - Local, - World, - Featured, - Bookmark, - Heart, - Twitter, - Mastodon, - Misskey, - Bluesky, - List, - Feeds, - Messages, - Rss, - Weibo, - Channel, - } - } + val icon: UiIcon, + ) : IconType() @Immutable @Serializable public data class Mixed( - val icon: Material.MaterialIcon, + val icon: UiIcon, val userKey: MicroBlogKey, ) : IconType() } @@ -160,7 +155,7 @@ public data object AllNotificationTabItem : TabItem() { override val metaData: TabMetaData = TabMetaData( title = TitleType.Localized(TitleType.Localized.LocalizedKey.Notifications), - icon = IconType.Material(IconType.Material.MaterialIcon.Notification), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.Notification), ) override val account: AccountType = AccountType.Guest override val key: String = "all_notification" @@ -185,8 +180,8 @@ public data class NotificationTabItem( public sealed class TimelineTabItem : TabItem() { public abstract fun createPresenter(): TimelinePresenter - public companion object { - public fun default(accountKey: MicroBlogKey?): ImmutableList = + internal companion object { + internal fun default(accountKey: MicroBlogKey?): ImmutableList = accountKey?.let { val accountType = AccountType.Specific(it) persistentListOf( @@ -195,7 +190,7 @@ public sealed class TimelineTabItem : TabItem() { metaData = TabMetaData( title = TitleType.Localized(TitleType.Localized.LocalizedKey.Home), - icon = IconType.Material(IconType.Material.MaterialIcon.Home), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.Home), ), ), NotificationTabItem( @@ -203,7 +198,7 @@ public sealed class TimelineTabItem : TabItem() { metaData = TabMetaData( title = TitleType.Localized(TitleType.Localized.LocalizedKey.Notifications), - icon = IconType.Material(IconType.Material.MaterialIcon.Notification), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.Notification), ), ), DiscoverTabItem( @@ -211,13 +206,13 @@ public sealed class TimelineTabItem : TabItem() { metaData = TabMetaData( title = TitleType.Localized(TitleType.Localized.LocalizedKey.Discover), - icon = IconType.Material(IconType.Material.MaterialIcon.Search), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.Search), ), ), ) } ?: guest - public fun mainSidePanel(accountKey: MicroBlogKey?): ImmutableList = + internal fun mainSidePanel(accountKey: MicroBlogKey?): ImmutableList = accountKey?.let { val accountType = AccountType.Specific(it) persistentListOf( @@ -226,7 +221,7 @@ public sealed class TimelineTabItem : TabItem() { metaData = TabMetaData( title = TitleType.Localized(TitleType.Localized.LocalizedKey.Home), - icon = IconType.Material(IconType.Material.MaterialIcon.Home), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.Home), ), ), NotificationTabItem( @@ -234,14 +229,14 @@ public sealed class TimelineTabItem : TabItem() { metaData = TabMetaData( title = TitleType.Localized(TitleType.Localized.LocalizedKey.Notifications), - icon = IconType.Material(IconType.Material.MaterialIcon.Notification), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.Notification), ), ), RssTabItem( metaData = TabMetaData( title = TitleType.Localized(TitleType.Localized.LocalizedKey.Rss), - icon = IconType.Material(IconType.Material.MaterialIcon.Rss), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.Rss), ), ), DiscoverTabItem( @@ -249,20 +244,20 @@ public sealed class TimelineTabItem : TabItem() { metaData = TabMetaData( title = TitleType.Localized(TitleType.Localized.LocalizedKey.Discover), - icon = IconType.Material(IconType.Material.MaterialIcon.Search), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.Search), ), ), ) } ?: guest - public val guest: ImmutableList = + internal val guest: ImmutableList = persistentListOf( HomeTimelineTabItem( account = AccountType.Guest, metaData = TabMetaData( title = TitleType.Localized(TitleType.Localized.LocalizedKey.Home), - icon = IconType.Material(IconType.Material.MaterialIcon.Home), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.Home), ), ), DiscoverTabItem( @@ -270,356 +265,16 @@ public sealed class TimelineTabItem : TabItem() { metaData = TabMetaData( title = TitleType.Localized(TitleType.Localized.LocalizedKey.Discover), - icon = IconType.Material(IconType.Material.MaterialIcon.Search), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.Search), ), ), SettingsTabItem, ) - public fun defaultPrimary(user: UiProfile): ImmutableList = - when (user.platformType) { - PlatformType.Mastodon -> mastodon(user.key) - PlatformType.Misskey -> misskey(user.key) - PlatformType.Bluesky -> bluesky(user.key) - PlatformType.xQt -> xqt(user.key) - PlatformType.VVo -> vvo(user.key) - } - - public fun defaultSecondary(user: UiAccount): ImmutableList { - val result = - when (user.platformType) { - PlatformType.Mastodon -> defaultMastodonSecondaryItems(user.accountKey) - PlatformType.Misskey -> defaultMisskeySecondaryItems(user.accountKey) - PlatformType.Bluesky -> defaultBlueskySecondaryItems(user.accountKey) - PlatformType.xQt -> defaultXqtSecondaryItems(user.accountKey) - PlatformType.VVo -> defaultVVOSecondaryItems(user.accountKey) - } - return result.toImmutableList() - } - - public fun secondaryFor(user: UiProfile): ImmutableList = - when (user.platformType) { - PlatformType.Mastodon -> defaultMastodonSecondaryItems(user.key) - PlatformType.Misskey -> defaultMisskeySecondaryItems(user.key) - PlatformType.Bluesky -> defaultBlueskySecondaryItems(user.key) - PlatformType.xQt -> defaultXqtSecondaryItems(user.key) - PlatformType.VVo -> defaultVVOSecondaryItems(user.key) - } - - private fun mastodon(accountKey: MicroBlogKey) = - persistentListOf( - HomeTimelineTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Home), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Home, accountKey), - ), - ), - DiscoverTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Discover), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Search, accountKey), - ), - ), - ProfileTabItem( - accountKey = accountKey, - userKey = accountKey, - ), - ) - - private fun defaultMastodonSecondaryItems(accountKey: MicroBlogKey) = - persistentListOf( - Mastodon.LocalTimelineTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.MastodonLocal), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Local, accountKey), - ), - ), - Mastodon.PublicTimelineTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.MastodonPublic), - icon = IconType.Mixed(IconType.Material.MaterialIcon.World, accountKey), - ), - ), - Mastodon.BookmarkTimelineTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Bookmark), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Bookmark, accountKey), - ), - ), - Mastodon.FavouriteTimelineTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Favourite), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Heart, accountKey), - ), - ), - AllListTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.List), - icon = IconType.Mixed(IconType.Material.MaterialIcon.List, accountKey), - ), - ), - ) - - private fun misskey(accountKey: MicroBlogKey) = - persistentListOf( - HomeTimelineTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Home), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Home, accountKey), - ), - ), - DiscoverTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Discover), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Search, accountKey), - ), - ), - ProfileTabItem( - accountKey = accountKey, - userKey = accountKey, - ), - ) - - private fun defaultMisskeySecondaryItems(accountKey: MicroBlogKey) = - persistentListOf( - Misskey.FavouriteTimelineTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Favourite), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Heart, accountKey), - ), - ), - AllListTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.List), - icon = IconType.Mixed(IconType.Material.MaterialIcon.List, accountKey), - ), - ), - Misskey.HybridTimelineTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Social), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Featured, accountKey), - ), - ), - Misskey.LocalTimelineTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.MastodonLocal), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Local, accountKey), - ), - ), - Misskey.GlobalTimelineTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.MastodonPublic), - icon = IconType.Mixed(IconType.Material.MaterialIcon.World, accountKey), - ), - ), - Misskey.AntennasListTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Antenna), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Rss, accountKey), - ), - ), - Misskey.ChannelListTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Channel), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Channel, accountKey), - ), - ), - ) - - private fun bluesky(accountKey: MicroBlogKey) = - persistentListOf( - HomeTimelineTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Home), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Home, accountKey), - ), - ), - DiscoverTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Discover), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Search, accountKey), - ), - ), - ) - - private fun defaultBlueskySecondaryItems(accountKey: MicroBlogKey) = - persistentListOf( - AllListTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.List), - icon = IconType.Mixed(IconType.Material.MaterialIcon.List, accountKey), - ), - ), - Bluesky.FeedsTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Feeds), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Feeds, accountKey), - ), - ), - Bluesky.BookmarkTimelineTabItem( - accountType = AccountType.Specific(accountKey), - ), - DirectMessageTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.DirectMessage), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Messages, accountKey), - ), - ), - ) - - private fun xqt(accountKey: MicroBlogKey) = - persistentListOf( - HomeTimelineTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Home), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Home, accountKey), - ), - ), - DiscoverTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Discover), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Search, accountKey), - ), - ), - ProfileTabItem( - accountKey = accountKey, - userKey = accountKey, - ), - ) - - private fun defaultXqtSecondaryItems(accountKey: MicroBlogKey) = - persistentListOf( - XQT.FeaturedTimelineTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Featured), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Featured, accountKey), - ), - ), - XQT.BookmarkTimelineTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Bookmark), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Bookmark, accountKey), - ), - ), - AllListTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.List), - icon = IconType.Mixed(IconType.Material.MaterialIcon.List, accountKey), - ), - ), - DirectMessageTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.DirectMessage), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Messages, accountKey), - ), - ), - ) - - private fun vvo(accountKey: MicroBlogKey) = - persistentListOf( - HomeTimelineTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Home), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Home, accountKey), - ), - ), - DiscoverTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Discover), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Search, accountKey), - ), - ), - ProfileTabItem( - accountKey = accountKey, - userKey = accountKey, - ), - ) - - private fun defaultVVOSecondaryItems(accountKey: MicroBlogKey) = - persistentListOf( - VVo.FeaturedTimelineTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Featured), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Featured, accountKey), - ), - ), - VVo.FavoriteTimelineTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Bookmark), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Bookmark, accountKey), - ), - ), - VVo.LikedTimelineTabItem( - account = AccountType.Specific(accountKey), - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Liked), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Heart, accountKey), - ), - ), - ) + internal fun secondaryFor( + platformType: PlatformType, + accountKey: MicroBlogKey, + ): ImmutableList = platformType.spec.secondary(accountKey) } } @@ -640,17 +295,17 @@ public data class HomeTimelineTabItem( metaData = TabMetaData( title = TitleType.Localized(TitleType.Localized.LocalizedKey.Home), - icon = IconType.Material(IconType.Material.MaterialIcon.Home), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.Home), ), ) - public constructor(accountKey: MicroBlogKey, title: String) : + public constructor(accountKey: MicroBlogKey, title: String, icon: IconType = IconType.FavIcon(accountKey.host)) : this( account = AccountType.Specific(accountKey), metaData = TabMetaData( title = TitleType.Text(title), - icon = IconType.FavIcon(accountKey.host), + icon = icon, ), ) } @@ -662,7 +317,7 @@ public data class MixedTimelineTabItem( override val metaData: TabMetaData = TabMetaData( title = TitleType.Localized(TitleType.Localized.LocalizedKey.MixedTimeline), - icon = IconType.Material(IconType.Material.MaterialIcon.Rss), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.Rss), ), ) : TimelineTabItem() { override fun createPresenter(): TimelinePresenter = MixedTimelinePresenter(subTimelineTabItem.map { it.createPresenter() }) @@ -697,7 +352,7 @@ public data class ListTimelineTabItem( title = TitleType.Text(data.title), icon = IconType.Mixed( - icon = IconType.Material.MaterialIcon.List, + icon = dev.dimension.flare.ui.model.UiIcon.List, userKey = accountKey, ), ), @@ -730,9 +385,7 @@ public object Mastodon { ) : TimelineTabItem() { override val key: String = "local_$account" - override fun createPresenter(): TimelinePresenter = - dev.dimension.flare.ui.presenter.home.mastodon - .MastodonLocalTimelinePresenter(account) + override fun createPresenter(): TimelinePresenter = MastodonLocalTimelinePresenter(account) override fun update(metaData: TabMetaData): TabItem = copy(metaData = metaData) } @@ -745,9 +398,7 @@ public object Mastodon { ) : TimelineTabItem() { override val key: String = "public_$account" - override fun createPresenter(): TimelinePresenter = - dev.dimension.flare.ui.presenter.home.mastodon - .MastodonPublicTimelinePresenter(account) + override fun createPresenter(): TimelinePresenter = MastodonPublicTimelinePresenter(account) override fun update(metaData: TabMetaData): TabItem = copy(metaData = metaData) } @@ -760,9 +411,7 @@ public object Mastodon { ) : TimelineTabItem() { override val key: String = "bookmark_$account" - override fun createPresenter(): TimelinePresenter = - dev.dimension.flare.ui.presenter.home.mastodon - .MastodonBookmarkTimelinePresenter(account) + override fun createPresenter(): TimelinePresenter = MastodonBookmarkTimelinePresenter(account) override fun update(metaData: TabMetaData): TabItem = copy(metaData = metaData) } @@ -775,9 +424,7 @@ public object Mastodon { ) : TimelineTabItem() { override val key: String = "favourite_$account" - override fun createPresenter(): TimelinePresenter = - dev.dimension.flare.ui.presenter.home.mastodon - .MastodonFavouriteTimelinePresenter(account) + override fun createPresenter(): TimelinePresenter = MastodonFavouriteTimelinePresenter(account) override fun update(metaData: TabMetaData): TabItem = copy(metaData = metaData) } @@ -792,9 +439,7 @@ public object Misskey { ) : TimelineTabItem() { override val key: String = "local_$account" - override fun createPresenter(): TimelinePresenter = - dev.dimension.flare.ui.presenter.home.misskey - .MissKeyLocalTimelinePresenter(account) + override fun createPresenter(): TimelinePresenter = MissKeyLocalTimelinePresenter(account) override fun update(metaData: TabMetaData): TabItem = copy(metaData = metaData) } @@ -807,9 +452,7 @@ public object Misskey { ) : TimelineTabItem() { override val key: String = "global_$account" - override fun createPresenter(): TimelinePresenter = - dev.dimension.flare.ui.presenter.home.misskey - .MissKeyPublicTimelinePresenter(account) + override fun createPresenter(): TimelinePresenter = MissKeyPublicTimelinePresenter(account) override fun update(metaData: TabMetaData): TabItem = copy(metaData = metaData) } @@ -822,9 +465,7 @@ public object Misskey { ) : TimelineTabItem() { override val key: String = "hybrid_$account" - override fun createPresenter(): TimelinePresenter = - dev.dimension.flare.ui.presenter.home.misskey - .MisskeyHybridTimelinePresenter(account) + override fun createPresenter(): TimelinePresenter = MisskeyHybridTimelinePresenter(account) override fun update(metaData: TabMetaData): TabItem = copy(metaData = metaData) } @@ -837,9 +478,7 @@ public object Misskey { ) : TimelineTabItem() { override val key: String = "favourite_$account" - override fun createPresenter(): TimelinePresenter = - dev.dimension.flare.ui.presenter.home.misskey - .MisskeyFavouriteTimelinePresenter(account) + override fun createPresenter(): TimelinePresenter = MisskeyFavouriteTimelinePresenter(account) override fun update(metaData: TabMetaData): TabItem = copy(metaData = metaData) } @@ -870,7 +509,7 @@ public object Misskey { title = TitleType.Text(data.title), icon = IconType.Mixed( - icon = IconType.Material.MaterialIcon.Rss, + icon = dev.dimension.flare.ui.model.UiIcon.Rss, userKey = accountKey, ), ), @@ -878,9 +517,7 @@ public object Misskey { override val key: String = "antennas_${account}_$antennasId" - override fun createPresenter(): TimelinePresenter = - dev.dimension.flare.ui.presenter.list - .AntennasTimelinePresenter(account, antennasId) + override fun createPresenter(): TimelinePresenter = AntennasTimelinePresenter(account, antennasId) override fun update(metaData: TabMetaData): TabItem = copy(metaData = metaData) } @@ -900,7 +537,7 @@ public object Misskey { title = TitleType.Text(data.title), icon = IconType.Mixed( - icon = IconType.Material.MaterialIcon.List, + icon = dev.dimension.flare.ui.model.UiIcon.List, userKey = accountKey, ), ), @@ -908,9 +545,7 @@ public object Misskey { override val key: String = "channel_${account}_$channelId" - override fun createPresenter(): TimelinePresenter = - dev.dimension.flare.ui.presenter.list - .ChannelTimelinePresenter(account, channelId) + override fun createPresenter(): TimelinePresenter = ChannelTimelinePresenter(account, channelId) override fun update(metaData: TabMetaData): TabItem = copy(metaData = metaData) } @@ -936,9 +571,7 @@ public object XQT { ) : TimelineTabItem() { override val key: String = "featured_$account" - override fun createPresenter(): TimelinePresenter = - dev.dimension.flare.ui.presenter.home.xqt - .XQTFeaturedTimelinePresenter(account) + override fun createPresenter(): TimelinePresenter = XQTFeaturedTimelinePresenter(account) override fun update(metaData: TabMetaData): TabItem = copy(metaData = metaData) } @@ -951,9 +584,7 @@ public object XQT { ) : TimelineTabItem() { override val key: String = "bookmark_$account" - override fun createPresenter(): TimelinePresenter = - dev.dimension.flare.ui.presenter.home.xqt - .XQTBookmarkTimelinePresenter(account) + override fun createPresenter(): TimelinePresenter = XQTBookmarkTimelinePresenter(account) override fun update(metaData: TabMetaData): TabItem = copy(metaData = metaData) } @@ -965,14 +596,12 @@ public object XQT { override val metaData: TabMetaData = TabMetaData( title = TitleType.Localized(TitleType.Localized.LocalizedKey.Posts), - icon = IconType.Material(IconType.Material.MaterialIcon.List), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.List), ), ) : TimelineTabItem() { override val key: String = "device_follow_$account" - override fun createPresenter(): TimelinePresenter = - dev.dimension.flare.ui.presenter.home.xqt - .XQTDeviceFollowTimelinePresenter(account) + override fun createPresenter(): TimelinePresenter = XQTDeviceFollowTimelinePresenter(account) override fun update(metaData: TabMetaData): TabItem = copy(metaData = metaData) } @@ -1005,15 +634,13 @@ public object Bluesky { title = TitleType.Text(data.title), icon = IconType.Mixed( - icon = IconType.Material.MaterialIcon.Feeds, + icon = dev.dimension.flare.ui.model.UiIcon.Feeds, userKey = accountKey, ), ), ) - override fun createPresenter(): TimelinePresenter = - dev.dimension.flare.ui.presenter.home.bluesky - .BlueskyFeedTimelinePresenter(account, uri) + override fun createPresenter(): TimelinePresenter = BlueskyFeedTimelinePresenter(account, uri) override val key: String = "feed_${account}_$uri" @@ -1028,9 +655,7 @@ public object Bluesky { ) : TimelineTabItem() { override val key: String = "bookmark_$account" - override fun createPresenter(): TimelinePresenter = - dev.dimension.flare.ui.presenter.home.bluesky - .BlueskyBookmarkTimelinePresenter(account) + override fun createPresenter(): TimelinePresenter = BlueskyBookmarkTimelinePresenter(account) override fun update(metaData: TabMetaData): TabItem = copy(metaData = metaData) @@ -1040,7 +665,7 @@ public object Bluesky { metaData = TabMetaData( title = TitleType.Localized(TitleType.Localized.LocalizedKey.Bookmark), - icon = IconType.Material(IconType.Material.MaterialIcon.Bookmark), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.Bookmark), ), ) } @@ -1055,9 +680,7 @@ public object VVo { ) : TimelineTabItem() { override val key: String = "featured_$account" - override fun createPresenter(): TimelinePresenter = - dev.dimension.flare.ui.presenter.home - .DiscoverStatusTimelinePresenter(account) + override fun createPresenter(): TimelinePresenter = DiscoverStatusTimelinePresenter(account) override fun update(metaData: TabMetaData): TabItem = copy(metaData = metaData) } @@ -1070,9 +693,7 @@ public object VVo { ) : TimelineTabItem() { override val key: String = "favorite_$account" - override fun createPresenter(): TimelinePresenter = - dev.dimension.flare.ui.presenter.home.vvo - .VVOFavouriteTimelinePresenter(account) + override fun createPresenter(): TimelinePresenter = VVOFavouriteTimelinePresenter(account) override fun update(metaData: TabMetaData): TabItem = copy(metaData = metaData) } @@ -1085,9 +706,7 @@ public object VVo { ) : TimelineTabItem() { override val key: String = "liked_$account" - override fun createPresenter(): TimelinePresenter = - dev.dimension.flare.ui.presenter.home.vvo - .VVOLikeTimelinePresenter(account) + override fun createPresenter(): TimelinePresenter = VVOLikeTimelinePresenter(account) override fun update(metaData: TabMetaData): TabItem = copy(metaData = metaData) } @@ -1104,9 +723,7 @@ public data class RssTimelineTabItem( override val account: AccountType = AccountType.Guest override val key: String = "rss_$feedUrl" - override fun createPresenter(): TimelinePresenter = - dev.dimension.flare.ui.presenter.home.rss - .RssTimelinePresenter(feedUrl) + override fun createPresenter(): TimelinePresenter = RssTimelinePresenter(feedUrl) override fun update(metaData: TabMetaData): TabItem = copy(metaData = metaData) @@ -1119,7 +736,7 @@ public data class RssTimelineTabItem( icon = data.favIcon ?.let { IconType.Url(it) - } ?: IconType.Material(IconType.Material.MaterialIcon.Rss), + } ?: IconType.Material(dev.dimension.flare.ui.model.UiIcon.Rss), ), ) // public constructor( @@ -1141,7 +758,7 @@ public data class AllRssTimelineTabItem( override val metaData: TabMetaData = TabMetaData( title = TitleType.Localized(TitleType.Localized.LocalizedKey.AllRssFeeds), - icon = IconType.Material(IconType.Material.MaterialIcon.Rss), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.Rss), ), override val account: AccountType = AccountType.Guest, ) : TimelineTabItem() { @@ -1149,9 +766,7 @@ public data class AllRssTimelineTabItem( override fun update(metaData: TabMetaData): TabItem = copy(metaData = metaData) - override fun createPresenter(): TimelinePresenter = - dev.dimension.flare.ui.presenter.home.rss - .AllRssTimelinePresenter() + override fun createPresenter(): TimelinePresenter = AllRssTimelinePresenter() } @Immutable @@ -1170,7 +785,7 @@ public data class ProfileTabItem( metaData = TabMetaData( title = TitleType.Localized(TitleType.Localized.LocalizedKey.Me), - icon = IconType.Mixed(IconType.Material.MaterialIcon.Profile, accountKey), + icon = IconType.Mixed(dev.dimension.flare.ui.model.UiIcon.Profile, accountKey), ), ) @@ -1200,7 +815,7 @@ public data object SettingsTabItem : TabItem() { get() = TabMetaData( title = TitleType.Localized(TitleType.Localized.LocalizedKey.Settings), - icon = IconType.Material(IconType.Material.MaterialIcon.Settings), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.Settings), ) override fun update(metaData: TabMetaData): TabItem = this @@ -1229,7 +844,7 @@ public data class RssTabItem( } @OptIn(ExperimentalSerializationApi::class) -public object TabSettingsSerializer : OkioSerializer { +internal object TabSettingsSerializer : OkioSerializer { override val defaultValue: TabSettings get() = TabSettings() diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/bluesky/BlueskyPlatformDetector.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/bluesky/BlueskyPlatformDetector.kt new file mode 100644 index 000000000..8268eec1a --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/bluesky/BlueskyPlatformDetector.kt @@ -0,0 +1,21 @@ +package dev.dimension.flare.data.network.bluesky + +import dev.dimension.flare.data.network.nodeinfo.NodeData +import dev.dimension.flare.data.network.nodeinfo.PlatformDetector +import dev.dimension.flare.data.repository.tryRun +import dev.dimension.flare.model.PlatformType + +internal data object BlueskyPlatformDetector : PlatformDetector { + override val priority: Int = 80 + + override suspend fun detect(host: String): NodeData? = + tryRun { + BlueskyService("https://$host").describeServer().requireResponse() + NodeData( + host = host, + platformType = PlatformType.Bluesky, + software = PlatformType.Bluesky.name, + compatibleMode = false, + ) + }.getOrNull() +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/mastodon/MastodonPlatformDetector.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/mastodon/MastodonPlatformDetector.kt new file mode 100644 index 000000000..5b2717d28 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/mastodon/MastodonPlatformDetector.kt @@ -0,0 +1,51 @@ +package dev.dimension.flare.data.network.mastodon + +import dev.dimension.flare.data.network.nodeinfo.NodeData +import dev.dimension.flare.data.network.nodeinfo.NodeInfoService +import dev.dimension.flare.data.network.nodeinfo.PlatformDetector +import dev.dimension.flare.data.repository.tryRun +import dev.dimension.flare.model.PlatformType + +internal data object MastodonPlatformDetector : PlatformDetector { + override val priority: Int = 60 + + override suspend fun detect(host: String): NodeData? { + val nodeInfo = + tryRun { + NodeInfoService.fetchNodeInfo(host) + }.getOrNull() + + if (nodeInfo?.equals("mastodon", ignoreCase = true) == true) { + return NodeData( + host = host, + platformType = PlatformType.Mastodon, + software = nodeInfo, + compatibleMode = false, + ) + } + + return tryRun { + MastodonInstanceService("https://$host/").instance().let { + requireNotNull(it.title) + NodeData( + host = host, + platformType = PlatformType.Mastodon, + software = nodeInfo ?: PlatformType.Mastodon.name, + compatibleMode = true, + ) + } + }.getOrElse { + tryRun { + MastodonInstanceService("https://$host/").instanceV1().let { + requireNotNull(it.title) + NodeData( + host = host, + platformType = PlatformType.Mastodon, + software = nodeInfo ?: PlatformType.Mastodon.name, + compatibleMode = true, + ) + } + }.getOrNull() + } + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/MisskeyPlatformDetector.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/MisskeyPlatformDetector.kt new file mode 100644 index 000000000..c458eccf2 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/MisskeyPlatformDetector.kt @@ -0,0 +1,40 @@ +package dev.dimension.flare.data.network.misskey + +import dev.dimension.flare.data.network.misskey.api.model.MetaRequest +import dev.dimension.flare.data.network.nodeinfo.NodeData +import dev.dimension.flare.data.network.nodeinfo.NodeInfoService +import dev.dimension.flare.data.network.nodeinfo.PlatformDetector +import dev.dimension.flare.data.repository.tryRun +import dev.dimension.flare.model.PlatformType + +internal data object MisskeyPlatformDetector : PlatformDetector { + override val priority: Int = 70 + + override suspend fun detect(host: String): NodeData? { + val nodeInfo = + tryRun { + NodeInfoService.fetchNodeInfo(host) + }.getOrNull() + + if (nodeInfo?.equals("misskey", ignoreCase = true) == true) { + return NodeData( + host = host, + platformType = PlatformType.Misskey, + software = nodeInfo, + compatibleMode = false, + ) + } + + return tryRun { + MisskeyService("https://$host/api/").meta(MetaRequest()).let { + requireNotNull(it.name) + NodeData( + host = host, + platformType = PlatformType.Misskey, + software = nodeInfo ?: PlatformType.Misskey.name, + compatibleMode = true, + ) + } + }.getOrNull() + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nodeinfo/NodeInfoService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nodeinfo/NodeInfoService.kt index 20dcd9d30..2b3f6411d 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nodeinfo/NodeInfoService.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nodeinfo/NodeInfoService.kt @@ -1,30 +1,17 @@ package dev.dimension.flare.data.network.nodeinfo -import dev.dimension.flare.data.network.bluesky.BlueskyService import dev.dimension.flare.data.network.ktorClient -import dev.dimension.flare.data.network.mastodon.MastodonInstanceService -import dev.dimension.flare.data.network.misskey.MisskeyService -import dev.dimension.flare.data.network.misskey.api.model.MetaRequest import dev.dimension.flare.data.network.nodeinfo.model.NodeInfo import dev.dimension.flare.data.network.nodeinfo.model.Schema10 import dev.dimension.flare.data.network.nodeinfo.model.Schema11 import dev.dimension.flare.data.network.nodeinfo.model.Schema20 import dev.dimension.flare.data.network.nodeinfo.model.Schema21 -import dev.dimension.flare.data.repository.tryRun import dev.dimension.flare.model.PlatformType -import dev.dimension.flare.model.vvo -import dev.dimension.flare.model.vvoHost -import dev.dimension.flare.model.vvoHostLong -import dev.dimension.flare.model.vvoHostShort -import dev.dimension.flare.model.xqtHost -import dev.dimension.flare.model.xqtOldHost +import dev.dimension.flare.model.spec import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.http.URLBuilder import io.ktor.http.URLProtocol -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope internal data object NodeInfoService { private val supportedSchemas = @@ -41,13 +28,21 @@ internal data object NodeInfoService { "akkoma", ) + internal fun normalizeHost(host: String): String = + host + .trim() + .removePrefix("https://") + .removePrefix("http://") + .removeSuffix("/") + suspend fun fetchNodeInfo(host: String): String? { + val normalizedHost = normalizeHost(host) val response = ktorClient() .get( URLBuilder( protocol = URLProtocol.HTTPS, - host = host, + host = normalizedHost, pathSegments = listOf(".well-known", "nodeinfo"), ).build(), ).body() @@ -92,120 +87,15 @@ internal data object NodeInfoService { }.first() } - suspend fun detectPlatformType(host: String): NodeData = - coroutineScope { - val hostCleaned = - host - .trim() - .removePrefix("https://") - .removePrefix("http://") - .removeSuffix("/") - val xqt = listOf(xqtOldHost, "xqt.social", xqtHost) - if (xqt.any { it.equals(hostCleaned, ignoreCase = true) }) { - return@coroutineScope NodeData( - host = hostCleaned, - PlatformType.xQt, - PlatformType.xQt.name, - compatibleMode = false, - ) - } - val vvo = listOf(vvoHost, vvo, vvoHostShort, "vvo.social", vvoHostLong) - if (vvo.any { it.equals(hostCleaned, ignoreCase = true) }) { - return@coroutineScope NodeData( - host = hostCleaned, - PlatformType.VVo, - PlatformType.VVo.name, - compatibleMode = false, - ) - } - val nodeInfo = - async { - tryRun { - val nodeInfo = - tryRun { - fetchNodeInfo(hostCleaned) - }.getOrNull() - - if (nodeInfo != null && nodeInfo.equals("mastodon", ignoreCase = true)) { - NodeData( - host = hostCleaned, - platformType = PlatformType.Mastodon, - software = nodeInfo, - compatibleMode = false, - ) - } else if (nodeInfo != null && nodeInfo.equals("misskey", ignoreCase = true)) { - NodeData( - host = hostCleaned, - platformType = PlatformType.Misskey, - software = nodeInfo, - compatibleMode = !nodeInfo.equals("misskey", ignoreCase = true), - ) - } else if (nodeInfo != null) { - tryRun { - MisskeyService( - "https://$hostCleaned/api/", - ).meta(MetaRequest()).let { - requireNotNull(it.name) - // should be able to use as misskey - NodeData( - host = hostCleaned, - platformType = PlatformType.Misskey, - software = nodeInfo, - compatibleMode = true, - ) - } - }.getOrElse { - tryRun { - MastodonInstanceService("https://$hostCleaned/").instance().let { - requireNotNull(it.title) - // should be able to use as mastodon - NodeData( - host = hostCleaned, - platformType = PlatformType.Mastodon, - software = nodeInfo, - compatibleMode = true, - ) - } - }.getOrElse { - tryRun { - MastodonInstanceService("https://$hostCleaned/").instanceV1().let { - requireNotNull(it.title) - // should be able to use as mastodon - NodeData( - host = hostCleaned, - platformType = PlatformType.Mastodon, - software = nodeInfo, - compatibleMode = true, - ) - } - }.getOrNull() - } - } - } else { - null - } - }.getOrNull() - } - - val bluesky = - async { - tryRun { - BlueskyService("https://$hostCleaned").describeServer().requireResponse() - NodeData( - host = hostCleaned, - platformType = PlatformType.Bluesky, - software = PlatformType.Bluesky.name, - compatibleMode = false, - ) - }.getOrNull() - } - - listOf( - nodeInfo, - bluesky, - ).awaitAll().firstOrNull { it != null } - ?: throw IllegalArgumentException("Unsupported platform: $hostCleaned") - } + suspend fun detectPlatformType(host: String): NodeData { + val hostCleaned = normalizeHost(host) + return PlatformType.entries + .map { it.spec.detector } + .distinct() + .sortedByDescending { it.priority } + .firstNotNullOfOrNull { detector -> detector.detect(hostCleaned) } + ?: throw IllegalArgumentException("Unsupported platform: $hostCleaned") + } } public data class NodeData( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nodeinfo/PlatformDetector.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nodeinfo/PlatformDetector.kt new file mode 100644 index 000000000..58fda7dd4 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nodeinfo/PlatformDetector.kt @@ -0,0 +1,8 @@ +package dev.dimension.flare.data.network.nodeinfo + +internal interface PlatformDetector { + val priority: Int + get() = 0 + + suspend fun detect(host: String): NodeData? +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrPlatformDetector.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrPlatformDetector.kt new file mode 100644 index 000000000..bf3317695 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrPlatformDetector.kt @@ -0,0 +1,19 @@ +package dev.dimension.flare.data.network.nostr + +import dev.dimension.flare.data.network.nodeinfo.NodeData +import dev.dimension.flare.data.network.nodeinfo.PlatformDetector +import dev.dimension.flare.model.PlatformType + +internal data object NostrPlatformDetector : PlatformDetector { + override suspend fun detect(host: String): NodeData? { + if (!host.equals("nostr", ignoreCase = true)) { + return null + } + return NodeData( + host = host, + platformType = PlatformType.Nostr, + software = PlatformType.Nostr.name, + compatibleMode = false, + ) + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt index 1443b6674..091d30e13 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt @@ -43,19 +43,21 @@ import kotlinx.serialization.json.jsonPrimitive import kotlin.time.Clock import kotlin.time.Instant +public val defaultNostrRelays: List = + listOf( + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.primal.net", + "wss://relay.snort.social", + "wss://relay.nostr.band", + "wss://offchain.pub", + "wss://purplepag.es", + ) + internal object NostrService { internal const val NOSTR_HOST: String = "nostr" - internal val defaultRelays: List = - listOf( - "wss://relay.damus.io", - "wss://nos.lol", - "wss://relay.primal.net", - "wss://relay.snort.social", - "wss://relay.nostr.band", - "wss://offchain.pub", - "wss://purplepag.es", - ) + internal val defaultRelays: List = defaultNostrRelays internal data class ImportedAccount( val pubkeyHex: String, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/vvo/VVOPlatformDetector.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/vvo/VVOPlatformDetector.kt new file mode 100644 index 000000000..9e91a5d3d --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/vvo/VVOPlatformDetector.kt @@ -0,0 +1,26 @@ +package dev.dimension.flare.data.network.vvo + +import dev.dimension.flare.data.network.nodeinfo.NodeData +import dev.dimension.flare.data.network.nodeinfo.PlatformDetector +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.model.vvo +import dev.dimension.flare.model.vvoHost +import dev.dimension.flare.model.vvoHostLong +import dev.dimension.flare.model.vvoHostShort + +internal data object VVOPlatformDetector : PlatformDetector { + override val priority: Int = 90 + + override suspend fun detect(host: String): NodeData? { + val aliases = listOf(vvoHost, vvo, vvoHostShort, "vvo.social", vvoHostLong) + if (!aliases.any { it.equals(host, ignoreCase = true) }) { + return null + } + return NodeData( + host = host, + platformType = PlatformType.VVo, + software = PlatformType.VVo.name, + compatibleMode = false, + ) + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/XQTPlatformDetector.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/XQTPlatformDetector.kt new file mode 100644 index 000000000..3d1e2ddc8 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/XQTPlatformDetector.kt @@ -0,0 +1,24 @@ +package dev.dimension.flare.data.network.xqt + +import dev.dimension.flare.data.network.nodeinfo.NodeData +import dev.dimension.flare.data.network.nodeinfo.PlatformDetector +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.model.xqtHost +import dev.dimension.flare.model.xqtOldHost + +internal data object XQTPlatformDetector : PlatformDetector { + override val priority: Int = 100 + + override suspend fun detect(host: String): NodeData? { + val aliases = listOf(xqtOldHost, "xqt.social", xqtHost) + if (!aliases.any { it.equals(host, ignoreCase = true) }) { + return null + } + return NodeData( + host = host, + platformType = PlatformType.xQt, + software = PlatformType.xQt.name, + compatibleMode = false, + ) + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/BlueskyPlatformSpec.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/BlueskyPlatformSpec.kt new file mode 100644 index 000000000..736ea681f --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/BlueskyPlatformSpec.kt @@ -0,0 +1,82 @@ +package dev.dimension.flare.data.platform + +import dev.dimension.flare.common.deeplink.DeepLinkMapping +import dev.dimension.flare.common.deeplink.DeepLinkPattern +import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource +import dev.dimension.flare.data.model.AllListTabItem +import dev.dimension.flare.data.model.Bluesky +import dev.dimension.flare.data.model.DirectMessageTabItem +import dev.dimension.flare.data.model.IconType +import dev.dimension.flare.data.model.TabItem +import dev.dimension.flare.data.model.TabMetaData +import dev.dimension.flare.data.model.TitleType +import dev.dimension.flare.data.network.bluesky.BlueskyPlatformDetector +import dev.dimension.flare.data.network.nodeinfo.PlatformDetector +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformSpec +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.model.PlatformTypeMetadata +import dev.dimension.flare.ui.model.UiIcon +import dev.dimension.flare.ui.model.UiInstanceMetadata +import io.ktor.http.Url +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +internal data object BlueskyPlatformSpec : PlatformSpec { + override val type = PlatformType.Bluesky + override val metadata = + PlatformTypeMetadata( + displayName = "Bluesky", + logoUrl = "https://blueskyweb.xyz/images/apple-touch-icon.png", + icon = UiIcon.Bluesky, + ) + override val detector: PlatformDetector = BlueskyPlatformDetector + + override fun agreementUrl(host: String): String? = "https://bsky.social/about/support/tos" + + override fun deepLinkPatterns(host: String): ImmutableList> = + buildList { + add(DeepLinkPattern(DeepLinkMapping.Type.Profile.serializer(), Url("https://$host/profile/{handle}"))) + add(DeepLinkPattern(DeepLinkMapping.Type.BlueskyPost.serializer(), Url("https://$host/profile/{handle}/post/{id}"))) + if (host == "bsky.social") { + add(DeepLinkPattern(DeepLinkMapping.Type.Profile.serializer(), Url("https://bsky.app/profile/{handle}"))) + add(DeepLinkPattern(DeepLinkMapping.Type.BlueskyPost.serializer(), Url("https://bsky.app/profile/{handle}/post/{id}"))) + } + }.toImmutableList() + + override fun secondary(accountKey: MicroBlogKey): ImmutableList = + persistentListOf( + AllListTabItem( + AccountType.Specific(accountKey), + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.List), + icon = IconType.Mixed(dev.dimension.flare.ui.model.UiIcon.List, accountKey), + ), + ), + Bluesky.FeedsTabItem( + AccountType.Specific(accountKey), + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.Feeds), + icon = IconType.Mixed(dev.dimension.flare.ui.model.UiIcon.Feeds, accountKey), + ), + ), + Bluesky.BookmarkTimelineTabItem(AccountType.Specific(accountKey)), + DirectMessageTabItem( + AccountType.Specific(accountKey), + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.DirectMessage), + icon = IconType.Mixed(dev.dimension.flare.ui.model.UiIcon.Messages, accountKey), + ), + ), + ) + + override suspend fun instanceMetadata(host: String): UiInstanceMetadata = + throw UnsupportedOperationException("${type.name} is not supported yet") + + override fun guestDataSource( + host: String, + locale: String, + ): MicroblogDataSource = throw UnsupportedOperationException("${type.name} guest data source is not supported yet") +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/MastodonPlatformSpec.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/MastodonPlatformSpec.kt new file mode 100644 index 000000000..0a36f4eed --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/MastodonPlatformSpec.kt @@ -0,0 +1,95 @@ +package dev.dimension.flare.data.platform + +import dev.dimension.flare.common.deeplink.DeepLinkMapping +import dev.dimension.flare.common.deeplink.DeepLinkPattern +import dev.dimension.flare.data.datasource.guest.mastodon.GuestMastodonDataSource +import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource +import dev.dimension.flare.data.model.AllListTabItem +import dev.dimension.flare.data.model.IconType +import dev.dimension.flare.data.model.Mastodon +import dev.dimension.flare.data.model.TabItem +import dev.dimension.flare.data.model.TabMetaData +import dev.dimension.flare.data.model.TitleType +import dev.dimension.flare.data.network.mastodon.MastodonInstanceService +import dev.dimension.flare.data.network.mastodon.MastodonPlatformDetector +import dev.dimension.flare.data.network.nodeinfo.PlatformDetector +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformSpec +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.model.PlatformTypeMetadata +import dev.dimension.flare.ui.model.UiIcon +import dev.dimension.flare.ui.model.UiInstanceMetadata +import dev.dimension.flare.ui.model.mapper.render +import io.ktor.http.Url +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal data object MastodonPlatformSpec : PlatformSpec { + override val type = PlatformType.Mastodon + override val metadata = + PlatformTypeMetadata( + displayName = "Mastodon", + logoUrl = "https://joinmastodon.org/logos/logo-purple.svg", + icon = UiIcon.Mastodon, + ) + override val detector: PlatformDetector = MastodonPlatformDetector + + override fun agreementUrl(host: String): String? = "https://$host/about" + + override fun deepLinkPatterns(host: String): ImmutableList> = + persistentListOf( + DeepLinkPattern(DeepLinkMapping.Type.Profile.serializer(), Url("https://$host/@{handle}")), + DeepLinkPattern(DeepLinkMapping.Type.Post.serializer(), Url("https://$host/@{handle}/{id}")), + ) + + override fun secondary(accountKey: MicroBlogKey): ImmutableList = + persistentListOf( + Mastodon.LocalTimelineTabItem( + AccountType.Specific(accountKey), + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.MastodonLocal), + icon = IconType.Mixed(dev.dimension.flare.ui.model.UiIcon.Local, accountKey), + ), + ), + Mastodon.PublicTimelineTabItem( + AccountType.Specific(accountKey), + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.MastodonPublic), + icon = IconType.Mixed(dev.dimension.flare.ui.model.UiIcon.World, accountKey), + ), + ), + Mastodon.BookmarkTimelineTabItem( + AccountType.Specific(accountKey), + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.Bookmark), + icon = IconType.Mixed(dev.dimension.flare.ui.model.UiIcon.Bookmark, accountKey), + ), + ), + Mastodon.FavouriteTimelineTabItem( + AccountType.Specific(accountKey), + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.Favourite), + icon = IconType.Mixed(dev.dimension.flare.ui.model.UiIcon.Heart, accountKey), + ), + ), + AllListTabItem( + AccountType.Specific(accountKey), + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.List), + icon = IconType.Mixed(dev.dimension.flare.ui.model.UiIcon.List, accountKey), + ), + ), + ) + + override suspend fun instanceMetadata(host: String): UiInstanceMetadata = MastodonInstanceService("https://$host/").instance().render() + + override fun guestDataSource( + host: String, + locale: String, + ): MicroblogDataSource = + GuestMastodonDataSource( + host = host, + locale = locale, + ) +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/MisskeyPlatformSpec.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/MisskeyPlatformSpec.kt new file mode 100644 index 000000000..575d15789 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/MisskeyPlatformSpec.kt @@ -0,0 +1,106 @@ +package dev.dimension.flare.data.platform + +import dev.dimension.flare.common.deeplink.DeepLinkMapping +import dev.dimension.flare.common.deeplink.DeepLinkPattern +import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource +import dev.dimension.flare.data.model.AllListTabItem +import dev.dimension.flare.data.model.IconType +import dev.dimension.flare.data.model.Misskey +import dev.dimension.flare.data.model.TabItem +import dev.dimension.flare.data.model.TabMetaData +import dev.dimension.flare.data.model.TitleType +import dev.dimension.flare.data.network.misskey.MisskeyPlatformDetector +import dev.dimension.flare.data.network.misskey.MisskeyService +import dev.dimension.flare.data.network.misskey.api.model.MetaRequest +import dev.dimension.flare.data.network.nodeinfo.PlatformDetector +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformSpec +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.model.PlatformTypeMetadata +import dev.dimension.flare.ui.model.UiIcon +import dev.dimension.flare.ui.model.UiInstanceMetadata +import dev.dimension.flare.ui.model.mapper.render +import io.ktor.http.Url +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal data object MisskeyPlatformSpec : PlatformSpec { + override val type = PlatformType.Misskey + override val metadata = + PlatformTypeMetadata( + displayName = "Misskey", + logoUrl = "https://github.com/misskey-dev/misskey/blob/develop/packages/backend/assets/favicon.png?raw=true", + icon = UiIcon.Misskey, + ) + override val detector: PlatformDetector = MisskeyPlatformDetector + + override fun agreementUrl(host: String): String? = "https://$host/about" + + override fun deepLinkPatterns(host: String): ImmutableList> = + persistentListOf( + DeepLinkPattern(DeepLinkMapping.Type.Profile.serializer(), Url("https://$host/@{handle}")), + DeepLinkPattern(DeepLinkMapping.Type.Post.serializer(), Url("https://$host/notes/{id}")), + ) + + override fun secondary(accountKey: MicroBlogKey): ImmutableList = + persistentListOf( + Misskey.FavouriteTimelineTabItem( + AccountType.Specific(accountKey), + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.Favourite), + icon = IconType.Mixed(dev.dimension.flare.ui.model.UiIcon.Heart, accountKey), + ), + ), + AllListTabItem( + AccountType.Specific(accountKey), + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.List), + icon = IconType.Mixed(dev.dimension.flare.ui.model.UiIcon.List, accountKey), + ), + ), + Misskey.HybridTimelineTabItem( + AccountType.Specific(accountKey), + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.Social), + icon = IconType.Mixed(dev.dimension.flare.ui.model.UiIcon.Featured, accountKey), + ), + ), + Misskey.LocalTimelineTabItem( + AccountType.Specific(accountKey), + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.MastodonLocal), + icon = IconType.Mixed(dev.dimension.flare.ui.model.UiIcon.Local, accountKey), + ), + ), + Misskey.GlobalTimelineTabItem( + AccountType.Specific(accountKey), + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.MastodonPublic), + icon = IconType.Mixed(dev.dimension.flare.ui.model.UiIcon.World, accountKey), + ), + ), + Misskey.AntennasListTabItem( + AccountType.Specific(accountKey), + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.Antenna), + icon = IconType.Mixed(dev.dimension.flare.ui.model.UiIcon.Rss, accountKey), + ), + ), + Misskey.ChannelListTabItem( + AccountType.Specific(accountKey), + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.Channel), + icon = IconType.Mixed(dev.dimension.flare.ui.model.UiIcon.Channel, accountKey), + ), + ), + ) + + override suspend fun instanceMetadata(host: String): UiInstanceMetadata = + MisskeyService("https://$host/api/").meta(MetaRequest()).render() + + override fun guestDataSource( + host: String, + locale: String, + ): MicroblogDataSource = throw UnsupportedOperationException("${type.name} guest data source is not supported yet") +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/NostrPlatformSpec.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/NostrPlatformSpec.kt new file mode 100644 index 000000000..a58f83ab1 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/NostrPlatformSpec.kt @@ -0,0 +1,41 @@ +package dev.dimension.flare.data.platform + +import dev.dimension.flare.common.deeplink.DeepLinkMapping +import dev.dimension.flare.common.deeplink.DeepLinkPattern +import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource +import dev.dimension.flare.data.model.TabItem +import dev.dimension.flare.data.network.nodeinfo.PlatformDetector +import dev.dimension.flare.data.network.nostr.NostrPlatformDetector +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformSpec +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.model.PlatformTypeMetadata +import dev.dimension.flare.ui.model.UiIcon +import dev.dimension.flare.ui.model.UiInstanceMetadata +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal data object NostrPlatformSpec : PlatformSpec { + override val type = PlatformType.Nostr + override val metadata = + PlatformTypeMetadata( + displayName = "Nostr", + logoUrl = "https://nostr.com/favicon.ico", + icon = UiIcon.Nostr, + ) + override val detector: PlatformDetector = NostrPlatformDetector + + override fun agreementUrl(host: String): String? = null + + override fun deepLinkPatterns(host: String): ImmutableList> = persistentListOf() + + override fun secondary(accountKey: MicroBlogKey): ImmutableList = persistentListOf() + + override suspend fun instanceMetadata(host: String): UiInstanceMetadata = + throw UnsupportedOperationException("${type.name} is not supported yet") + + override fun guestDataSource( + host: String, + locale: String, + ): MicroblogDataSource = throw UnsupportedOperationException("${type.name} guest data source is not supported yet") +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/VvoPlatformSpec.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/VvoPlatformSpec.kt new file mode 100644 index 000000000..54a407046 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/VvoPlatformSpec.kt @@ -0,0 +1,70 @@ +package dev.dimension.flare.data.platform + +import dev.dimension.flare.common.deeplink.DeepLinkMapping +import dev.dimension.flare.common.deeplink.DeepLinkPattern +import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource +import dev.dimension.flare.data.model.IconType +import dev.dimension.flare.data.model.TabItem +import dev.dimension.flare.data.model.TabMetaData +import dev.dimension.flare.data.model.TitleType +import dev.dimension.flare.data.model.VVo +import dev.dimension.flare.data.network.nodeinfo.PlatformDetector +import dev.dimension.flare.data.network.vvo.VVOPlatformDetector +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformSpec +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.model.PlatformTypeMetadata +import dev.dimension.flare.model.vvo +import dev.dimension.flare.ui.model.UiIcon +import dev.dimension.flare.ui.model.UiInstanceMetadata +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal data object VvoPlatformSpec : PlatformSpec { + override val type = PlatformType.VVo + override val metadata = + PlatformTypeMetadata( + displayName = vvo, + logoUrl = "https://upload.wikimedia.org/wikipedia/en/thumb/6/6e/Sina_Weibo.svg/2560px-Sina_Weibo.svg.png", + icon = UiIcon.Weibo, + ) + override val detector: PlatformDetector = VVOPlatformDetector + + override fun agreementUrl(host: String): String? = null + + override fun deepLinkPatterns(host: String): ImmutableList> = persistentListOf() + + override fun secondary(accountKey: MicroBlogKey): ImmutableList = + persistentListOf( + VVo.FeaturedTimelineTabItem( + AccountType.Specific(accountKey), + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.Featured), + icon = IconType.Mixed(dev.dimension.flare.ui.model.UiIcon.Featured, accountKey), + ), + ), + VVo.FavoriteTimelineTabItem( + AccountType.Specific(accountKey), + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.Bookmark), + icon = IconType.Mixed(dev.dimension.flare.ui.model.UiIcon.Bookmark, accountKey), + ), + ), + VVo.LikedTimelineTabItem( + AccountType.Specific(accountKey), + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.Liked), + icon = IconType.Mixed(dev.dimension.flare.ui.model.UiIcon.Heart, accountKey), + ), + ), + ) + + override suspend fun instanceMetadata(host: String): UiInstanceMetadata = + throw UnsupportedOperationException("${type.name} is not supported yet") + + override fun guestDataSource( + host: String, + locale: String, + ): MicroblogDataSource = throw UnsupportedOperationException("${type.name} guest data source is not supported yet") +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/XqtPlatformSpec.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/XqtPlatformSpec.kt new file mode 100644 index 000000000..525b280be --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/XqtPlatformSpec.kt @@ -0,0 +1,111 @@ +package dev.dimension.flare.data.platform + +import dev.dimension.flare.common.deeplink.DeepLinkMapping +import dev.dimension.flare.common.deeplink.DeepLinkPattern +import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource +import dev.dimension.flare.data.model.AllListTabItem +import dev.dimension.flare.data.model.DirectMessageTabItem +import dev.dimension.flare.data.model.IconType +import dev.dimension.flare.data.model.TabItem +import dev.dimension.flare.data.model.TabMetaData +import dev.dimension.flare.data.model.TitleType +import dev.dimension.flare.data.model.XQT +import dev.dimension.flare.data.network.nodeinfo.PlatformDetector +import dev.dimension.flare.data.network.xqt.XQTPlatformDetector +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformSpec +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.model.PlatformTypeMetadata +import dev.dimension.flare.model.xqtHost +import dev.dimension.flare.model.xqtOldHost +import dev.dimension.flare.ui.model.UiIcon +import dev.dimension.flare.ui.model.UiInstanceMetadata +import io.ktor.http.Url +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +internal data object XqtPlatformSpec : PlatformSpec { + override val type = PlatformType.xQt + override val metadata = + PlatformTypeMetadata( + displayName = "X", + logoUrl = + "https://upload.wikimedia.org/wikipedia/commons" + + "/thumb/5/53/X_logo_2023_original.svg/1920px-X_logo_2023_original.svg.png", + icon = UiIcon.X, + ) + override val detector: PlatformDetector = XQTPlatformDetector + + override fun agreementUrl(host: String): String? = "https://help.x.com/en/rules-and-policies/x-rules" + + override fun deepLinkPatterns(host: String): ImmutableList> { + val profile = + listOf( + "https://$xqtHost/{handle}", + "https://$xqtOldHost/{handle}", + "https://www.$xqtHost/{handle}", + "https://www.$xqtOldHost/{handle}", + ) + val post = + listOf( + "https://$xqtHost/{handle}/status/{id}", + "https://$xqtOldHost/{handle}/", + "https://www.$xqtHost/{handle}/status/{id}", + "https://www.$xqtOldHost/{handle}/", + ) + val media = + listOf( + "https://$xqtHost/{handle}/status/{id}/photo/{index}", + "https://$xqtOldHost/{handle}/status/{id}/photo/{index}", + "https://www.$xqtHost/{handle}/status/{id}/photo/{index}", + "https://www.$xqtOldHost/{handle}/status/{id}/photo/{index}", + ) + return ( + profile.map { DeepLinkPattern(DeepLinkMapping.Type.Profile.serializer(), Url(it)) } + + post.map { DeepLinkPattern(DeepLinkMapping.Type.Post.serializer(), Url(it)) } + + media.map { DeepLinkPattern(DeepLinkMapping.Type.PostMedia.serializer(), Url(it)) } + ).toImmutableList() + } + + override fun secondary(accountKey: MicroBlogKey): ImmutableList = + persistentListOf( + XQT.FeaturedTimelineTabItem( + AccountType.Specific(accountKey), + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.Featured), + icon = IconType.Mixed(dev.dimension.flare.ui.model.UiIcon.Featured, accountKey), + ), + ), + XQT.BookmarkTimelineTabItem( + AccountType.Specific(accountKey), + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.Bookmark), + icon = IconType.Mixed(dev.dimension.flare.ui.model.UiIcon.Bookmark, accountKey), + ), + ), + AllListTabItem( + AccountType.Specific(accountKey), + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.List), + icon = IconType.Mixed(dev.dimension.flare.ui.model.UiIcon.List, accountKey), + ), + ), + DirectMessageTabItem( + AccountType.Specific(accountKey), + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.DirectMessage), + icon = IconType.Mixed(dev.dimension.flare.ui.model.UiIcon.Messages, accountKey), + ), + ), + ) + + override suspend fun instanceMetadata(host: String): UiInstanceMetadata = + throw UnsupportedOperationException("${type.name} is not supported yet") + + override fun guestDataSource( + host: String, + locale: String, + ): MicroblogDataSource = throw UnsupportedOperationException("${type.name} guest data source is not supported yet") +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/AccountRepository.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/AccountRepository.kt index 7085f08f6..0d6b169e9 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/AccountRepository.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/AccountRepository.kt @@ -13,12 +13,12 @@ import dev.dimension.flare.data.database.app.AppDatabase import dev.dimension.flare.data.database.app.model.DbAccount import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.connect -import dev.dimension.flare.data.datasource.guest.mastodon.GuestMastodonDataSource import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource import dev.dimension.flare.data.datastore.AppDataStore import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.model.spec import dev.dimension.flare.ui.model.UiAccount import dev.dimension.flare.ui.model.UiAccount.Companion.createDataSource import dev.dimension.flare.ui.model.UiAccount.Companion.toUi @@ -253,15 +253,10 @@ internal fun accountServiceFlow( AccountType.Guest -> { val guestData = repository.appDataStore.guestDataStore.data guestData.map { - when (it.platformType) { - PlatformType.Mastodon -> - GuestMastodonDataSource( - host = it.host, - locale = Locale.language, - ) - - else -> throw UnsupportedOperationException() - } + it.platformType.spec.guestDataSource( + host = it.host, + locale = Locale.language, + ) } } diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/repository/SettingsRepository.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/SettingsRepository.kt similarity index 100% rename from compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/repository/SettingsRepository.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/SettingsRepository.kt diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt index de5f75085..db6ce625a 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt @@ -10,6 +10,7 @@ import dev.dimension.flare.data.repository.DraftMediaStore import dev.dimension.flare.data.repository.DraftRepository import dev.dimension.flare.data.repository.LocalFilterRepository import dev.dimension.flare.data.repository.SearchHistoryRepository +import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.ui.presenter.compose.ComposeUseCase import dev.dimension.flare.ui.presenter.compose.RestoreDraftUseCase import dev.dimension.flare.ui.presenter.compose.SaveDraftUseCase @@ -48,6 +49,7 @@ internal val commonModule = } singleOf(::ComposeUseCase) singleOf(::SearchHistoryRepository) + singleOf(::SettingsRepository) singleOf(::Readability) singleOf(::OpenAIService) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformSpec.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformSpec.kt new file mode 100644 index 000000000..d3a9c02df --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformSpec.kt @@ -0,0 +1,53 @@ +package dev.dimension.flare.model + +import dev.dimension.flare.common.deeplink.DeepLinkMapping +import dev.dimension.flare.common.deeplink.DeepLinkPattern +import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource +import dev.dimension.flare.data.model.TabItem +import dev.dimension.flare.data.network.nodeinfo.PlatformDetector +import dev.dimension.flare.data.platform.BlueskyPlatformSpec +import dev.dimension.flare.data.platform.MastodonPlatformSpec +import dev.dimension.flare.data.platform.MisskeyPlatformSpec +import dev.dimension.flare.data.platform.NostrPlatformSpec +import dev.dimension.flare.data.platform.VvoPlatformSpec +import dev.dimension.flare.data.platform.XqtPlatformSpec +import dev.dimension.flare.ui.model.UiInstanceMetadata +import kotlinx.collections.immutable.ImmutableList + +internal interface PlatformSpec { + val type: PlatformType + val metadata: PlatformTypeMetadata + val detector: PlatformDetector + + fun agreementUrl(host: String): String? + + fun deepLinkPatterns(host: String): ImmutableList> + + fun secondary(accountKey: MicroBlogKey): ImmutableList + + suspend fun instanceMetadata(host: String): UiInstanceMetadata + + fun guestDataSource( + host: String, + locale: String, + ): MicroblogDataSource +} + +public val PlatformType.logoUrl: String + get() = spec.metadata.logoUrl + +public val PlatformType.icon: dev.dimension.flare.ui.model.UiIcon + get() = spec.metadata.icon + +public fun PlatformType.agreementUrl(host: String): String? = spec.agreementUrl(host) + +internal val PlatformType.spec: PlatformSpec + get() = + when (this) { + PlatformType.Nostr -> NostrPlatformSpec + PlatformType.Mastodon -> MastodonPlatformSpec + PlatformType.Misskey -> MisskeyPlatformSpec + PlatformType.Bluesky -> BlueskyPlatformSpec + PlatformType.xQt -> XqtPlatformSpec + PlatformType.VVo -> VvoPlatformSpec + } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformType.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformType.kt index 36dbfa2d3..93a8ae3bc 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformType.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformType.kt @@ -1,6 +1,7 @@ package dev.dimension.flare.model import androidx.compose.runtime.Immutable +import dev.dimension.flare.ui.model.UiIcon import kotlinx.serialization.Serializable import kotlin.io.encoding.Base64 @@ -22,67 +23,9 @@ public enum class PlatformType { public data class PlatformTypeMetadata( val displayName: String, val logoUrl: String, + val icon: UiIcon, ) -public val PlatformType.metadata: PlatformTypeMetadata - get() = - when (this) { - PlatformType.Nostr -> - PlatformTypeMetadata( - displayName = "Nostr", - logoUrl = "https://nostr.com/favicon.ico", - ) - PlatformType.Mastodon -> - PlatformTypeMetadata( - displayName = "Mastodon", - logoUrl = "https://joinmastodon.org/logos/logo-purple.svg", - ) - PlatformType.Misskey -> - PlatformTypeMetadata( - displayName = "Misskey", - logoUrl = - "https://github.com/misskey-dev/misskey/blob/develop/packages" + - "/backend/assets/favicon.png?raw=true", - ) - PlatformType.Bluesky -> - PlatformTypeMetadata( - displayName = "Bluesky", - logoUrl = "https://blueskyweb.xyz/images/apple-touch-icon.png", - ) - PlatformType.xQt -> - PlatformTypeMetadata( - displayName = "X", - logoUrl = - "https://upload.wikimedia.org/wikipedia/commons/thumb/5/53" + - "/X_logo_2023_original.svg/1920px-X_logo_2023_original.svg.png", - ) - PlatformType.VVo -> - PlatformTypeMetadata( - displayName = vvo, - logoUrl = - "https://upload.wikimedia.org/wikipedia/en/thumb/6/" + - "6e/Sina_Weibo.svg/2560px-Sina_Weibo.svg.png", - ) - } - -public val PlatformType.displayName: String - get() = metadata.displayName - -public val PlatformType.logoUrl: String - get() = metadata.logoUrl - -public fun PlatformType.agreementUrl(host: String): String? = - when (this) { - PlatformType.Nostr, - PlatformType.VVo, - -> null - PlatformType.Bluesky -> "https://bsky.social/about/support/tos" - PlatformType.xQt -> "https://help.x.com/en/rules-and-policies/x-rules" - PlatformType.Mastodon, - PlatformType.Misskey, - -> "https://$host/about" - } - public val xqtOldHost: String = buildString { append(Base64.decode("dHc=").decodeToString()) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiIcon.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiIcon.kt index bc2f5a209..0adf849ff 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiIcon.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiIcon.kt @@ -6,6 +6,27 @@ import kotlinx.serialization.Serializable @Serializable @Immutable public enum class UiIcon { + Home, + Notification, + Search, + Profile, + Settings, + Local, + World, + Featured, + Bookmark, + Heart, + Twitter, + Mastodon, + Misskey, + Bluesky, + List, + Feeds, + Messages, + Rss, + Weibo, + Channel, + Like, Unlike, Retweet, @@ -13,7 +34,6 @@ public enum class UiIcon { Reply, Comment, Quote, - Bookmark, Unbookmark, More, MoreVerticel, @@ -22,13 +42,11 @@ public enum class UiIcon { React, UnReact, Share, - List, ChatMessage, Mute, UnMute, Block, UnBlock, - Follow, Favourite, Mention, @@ -37,4 +55,6 @@ public enum class UiIcon { Info, Pin, Check, + Nostr, + X, } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiRssSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiRssSource.kt index 542aac01b..da125cfdf 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiRssSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiRssSource.kt @@ -2,7 +2,7 @@ package dev.dimension.flare.ui.model import androidx.compose.runtime.Immutable import dev.dimension.flare.model.PlatformType -import dev.dimension.flare.model.logoUrl +import dev.dimension.flare.model.spec import dev.dimension.flare.model.vvo import dev.dimension.flare.model.vvoHost import dev.dimension.flare.model.vvoHostLong @@ -38,7 +38,7 @@ public data class UiRssSource internal constructor( if (parsedUrl.host == BSKY_SOCIAL.host) { return "https://web-cdn.bsky.app/static/apple-touch-icon.png" } else if (parsedUrl.host in listOf(vvo, vvoHostShort, vvoHost, vvoHostLong)) { - return PlatformType.VVo.logoUrl + return PlatformType.VVo.spec.metadata.logoUrl } else { return "https://${parsedUrl.host}/favicon.ico" } diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/ExportDataPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/ExportDataPresenter.kt similarity index 100% rename from compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/ExportDataPresenter.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/ExportDataPresenter.kt diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/ExportSettingsPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/ExportSettingsPresenter.kt similarity index 100% rename from compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/ExportSettingsPresenter.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/ExportSettingsPresenter.kt diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTabsPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTabsPresenter.kt similarity index 96% rename from compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTabsPresenter.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTabsPresenter.kt index d6f24697f..7551c69f7 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTabsPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTabsPresenter.kt @@ -59,7 +59,10 @@ public class HomeTabsPresenter : ) } else { val secondary = - tabsState.secondaryItems ?: TimelineTabItem.defaultSecondary(account) + tabsState.secondaryItems ?: TimelineTabItem.secondaryFor( + account.platformType, + account.accountKey, + ) State.HomeTabState( primary = TimelineTabItem.default(account.accountKey).toImmutableList(), diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt similarity index 95% rename from compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt index ec231673b..a5af2d49c 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt @@ -5,11 +5,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import dev.dimension.flare.data.model.HomeTimelineTabItem +import dev.dimension.flare.data.model.IconType import dev.dimension.flare.data.model.MixedTimelineTabItem import dev.dimension.flare.data.model.TimelineTabItem import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.model.AccountType -import dev.dimension.flare.model.displayName +import dev.dimension.flare.model.icon +import dev.dimension.flare.model.spec import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.model.collectAsUiState import dev.dimension.flare.ui.presenter.home.UserPresenter @@ -79,7 +81,8 @@ public class HomeTimelineWithTabsPresenter( val tab = HomeTimelineTabItem( accountKey = account.accountKey, - title = account.platformType.displayName, + title = account.platformType.spec.metadata.displayName, + icon = IconType.Material(account.platformType.icon), ) settingsRepository.updateTabSettings { if (mainTabs.any { it.key == tab.key }) { diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/ImportDataPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/ImportDataPresenter.kt similarity index 100% rename from compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/ImportDataPresenter.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/ImportDataPresenter.kt diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/ImportSettingsPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/ImportSettingsPresenter.kt similarity index 100% rename from compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/ImportSettingsPresenter.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/ImportSettingsPresenter.kt diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/PinTabsPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/PinTabsPresenter.kt similarity index 100% rename from compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/PinTabsPresenter.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/PinTabsPresenter.kt diff --git a/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/presenter/SettingsPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/SettingsPresenter.kt similarity index 100% rename from compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/presenter/SettingsPresenter.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/SettingsPresenter.kt diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/DeepLinkPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/DeepLinkPresenter.kt index 92d4a694f..bcdd99c53 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/DeepLinkPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/DeepLinkPresenter.kt @@ -11,13 +11,13 @@ import dev.dimension.flare.data.datasource.microblog.datasource.PostDataSource import dev.dimension.flare.data.repository.AccountRepository import dev.dimension.flare.data.repository.accountServiceFlow import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.spec import dev.dimension.flare.ui.model.DeeplinkEvent import dev.dimension.flare.ui.presenter.PresenterBase import dev.dimension.flare.ui.route.APPSCHEMA import dev.dimension.flare.ui.route.DeeplinkRoute import io.ktor.http.URLProtocol import io.ktor.http.buildUrl -import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.firstOrNull @@ -42,11 +42,7 @@ public class DeepLinkPresenter( accountRepository.allAccounts.map { it .associateWith { - DeepLinkMapping - .generatePattern( - platformType = it.platformType, - host = it.accountKey.host, - ).toImmutableList() + it.platformType.spec.deepLinkPatterns(it.accountKey.host) }.toImmutableMap() } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/InstanceMetadataPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/InstanceMetadataPresenter.kt index dc9931827..f02bd9dd8 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/InstanceMetadataPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/InstanceMetadataPresenter.kt @@ -5,14 +5,11 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import dev.dimension.flare.data.network.mastodon.MastodonInstanceService -import dev.dimension.flare.data.network.misskey.MisskeyService -import dev.dimension.flare.data.network.misskey.api.model.MetaRequest import dev.dimension.flare.data.repository.tryRun import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.model.spec import dev.dimension.flare.ui.model.UiInstanceMetadata import dev.dimension.flare.ui.model.UiState -import dev.dimension.flare.ui.model.mapper.render import dev.dimension.flare.ui.presenter.PresenterBase import kotlinx.coroutines.flow.flow @@ -31,24 +28,7 @@ public class InstanceMetadataPresenter( flow { tryRun { emit(UiState.Loading()) - when (platformType) { - PlatformType.Nostr -> throw UnsupportedOperationException( - "Nostr is not supported yet", - ) - PlatformType.Mastodon -> - MastodonInstanceService("https://$host/").instance().render() - PlatformType.Misskey -> - MisskeyService("https://$host/api/").meta(MetaRequest()).render() - PlatformType.Bluesky -> throw UnsupportedOperationException( - "Bluesky is not supported yet", - ) - PlatformType.xQt -> throw UnsupportedOperationException( - "xQt is not supported yet", - ) - PlatformType.VVo -> throw UnsupportedOperationException( - "VVo is not supported yet", - ) - } + platformType.spec.instanceMetadata(host) }.fold( onSuccess = { emit(UiState.Success(it)) diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedsWithTabsPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedsWithTabsPresenter.kt similarity index 96% rename from compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedsWithTabsPresenter.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedsWithTabsPresenter.kt index f62711fc3..530d50988 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedsWithTabsPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedsWithTabsPresenter.kt @@ -37,7 +37,7 @@ public class BlueskyFeedsWithTabsPresenter( icon = item.let { it as? UiList.Feed }?.avatar?.let { IconType.Url(it) - } ?: IconType.Material(IconType.Material.MaterialIcon.Feeds), + } ?: IconType.Material(dev.dimension.flare.ui.model.UiIcon.Feeds), ), ) diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/list/AllListWithTabsPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/screen/list/AllListWithTabsPresenter.kt similarity index 96% rename from compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/list/AllListWithTabsPresenter.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/ui/screen/list/AllListWithTabsPresenter.kt index 6d2721ac5..cd99be8b9 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/list/AllListWithTabsPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/screen/list/AllListWithTabsPresenter.kt @@ -34,7 +34,7 @@ public class AllListWithTabsPresenter( icon = item.let { it as? UiList.List }?.avatar?.let { IconType.Url(it) - } ?: IconType.Material(IconType.Material.MaterialIcon.List), + } ?: IconType.Material(dev.dimension.flare.ui.model.UiIcon.List), ), ) diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/misskey/MisskeyAntennasListWithTabsPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/screen/misskey/MisskeyAntennasListWithTabsPresenter.kt similarity index 96% rename from compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/misskey/MisskeyAntennasListWithTabsPresenter.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/ui/screen/misskey/MisskeyAntennasListWithTabsPresenter.kt index 76cf94c7b..266268c21 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/misskey/MisskeyAntennasListWithTabsPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/screen/misskey/MisskeyAntennasListWithTabsPresenter.kt @@ -29,7 +29,7 @@ public class MisskeyAntennasListWithTabsPresenter( metaData = TabMetaData( title = TitleType.Text(item.title), - icon = IconType.Material(IconType.Material.MaterialIcon.List), + icon = IconType.Material(dev.dimension.flare.ui.model.UiIcon.List), ), ) diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/rss/RssListWithTabsPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/screen/rss/RssListWithTabsPresenter.kt similarity index 100% rename from compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/rss/RssListWithTabsPresenter.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/ui/screen/rss/RssListWithTabsPresenter.kt diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/settings/AccountManagementPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/screen/settings/AccountManagementPresenter.kt similarity index 100% rename from compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/settings/AccountManagementPresenter.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/ui/screen/settings/AccountManagementPresenter.kt diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/settings/AllTabsPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/screen/settings/AllTabsPresenter.kt similarity index 96% rename from compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/settings/AllTabsPresenter.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/ui/screen/settings/AllTabsPresenter.kt index bf499d291..2d422ce8d 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/settings/AllTabsPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/screen/settings/AllTabsPresenter.kt @@ -36,9 +36,10 @@ public class AllTabsPresenter( val tabs = remember(user.key) { ( - TimelineTabItem.defaultPrimary(user) + + TimelineTabItem.default(user.key) + TimelineTabItem.secondaryFor( - user, + user.platformType, + user.key, ) ).let { if (filterIsTimeline) { diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/common/deeplink/DeepLinkMappingTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/common/deeplink/DeepLinkMappingTest.kt index 0c491700c..038ef9276 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/common/deeplink/DeepLinkMappingTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/common/deeplink/DeepLinkMappingTest.kt @@ -3,6 +3,7 @@ package dev.dimension.flare.common.deeplink import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.model.spec import dev.dimension.flare.model.xqtHost import dev.dimension.flare.ui.model.UiAccount import dev.dimension.flare.ui.route.DeeplinkRoute @@ -10,7 +11,6 @@ import io.ktor.http.Url import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentMapOf -import kotlinx.collections.immutable.toImmutableList import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -20,7 +20,7 @@ class DeepLinkMappingTest { fun mastodonPatternsAreGeneratedInOrder() { val host = "mastodon.social" - val patterns = DeepLinkMapping.generatePattern(PlatformType.Mastodon, host) + val patterns = PlatformType.Mastodon.spec.deepLinkPatterns(host) assertEquals(2, patterns.size) @@ -49,7 +49,7 @@ class DeepLinkMappingTest { fun misskeyPatternsUseNotesRoute() { val host = "misskey.example" - val patterns = DeepLinkMapping.generatePattern(PlatformType.Misskey, host) + val patterns = PlatformType.Misskey.spec.deepLinkPatterns(host) assertEquals(2, patterns.size) @@ -78,7 +78,7 @@ class DeepLinkMappingTest { fun blueskyPatternsIncludeProfileAndPost() { val host = "bsky.example" - val patterns = DeepLinkMapping.generatePattern(PlatformType.Bluesky, host) + val patterns = PlatformType.Bluesky.spec.deepLinkPatterns(host) assertEquals(2, patterns.size) @@ -107,7 +107,7 @@ class DeepLinkMappingTest { fun xqtPatternsUseStatusRoute() { val host = xqtHost - val patterns = DeepLinkMapping.generatePattern(PlatformType.xQt, host) + val patterns = PlatformType.xQt.spec.deepLinkPatterns(host) assertEquals(12, patterns.size) @@ -150,7 +150,7 @@ class DeepLinkMappingTest { @Test fun vvoHasNoPatterns() { - val patterns = DeepLinkMapping.generatePattern(PlatformType.VVo, "irrelevant") + val patterns = PlatformType.VVo.spec.deepLinkPatterns("irrelevant") assertTrue(patterns.isEmpty()) } @@ -170,17 +170,9 @@ class DeepLinkMappingTest { val mapping = persistentMapOf( mastodonAccount to - DeepLinkMapping - .generatePattern( - PlatformType.Mastodon, - mastodonAccount.accountKey.host, - ).toImmutableList(), + PlatformType.Mastodon.spec.deepLinkPatterns(mastodonAccount.accountKey.host), misskeyAccount to - DeepLinkMapping - .generatePattern( - PlatformType.Misskey, - misskeyAccount.accountKey.host, - ).toImmutableList(), + PlatformType.Misskey.spec.deepLinkPatterns(misskeyAccount.accountKey.host), ) val matches = DeepLinkMapping.matches("https://mastodon.social/@alice", mapping) @@ -205,17 +197,9 @@ class DeepLinkMappingTest { ImmutableMap>> = persistentMapOf( account1 to - DeepLinkMapping - .generatePattern( - PlatformType.Mastodon, - account1.accountKey.host, - ).toImmutableList(), + PlatformType.Mastodon.spec.deepLinkPatterns(account1.accountKey.host), account2 to - DeepLinkMapping - .generatePattern( - PlatformType.Mastodon, - account2.accountKey.host, - ).toImmutableList(), + PlatformType.Mastodon.spec.deepLinkPatterns(account2.accountKey.host), ) val matches = DeepLinkMapping.matches("https://mastodon.social/@alice", mapping) @@ -236,11 +220,7 @@ class DeepLinkMappingTest { ImmutableMap>> = persistentMapOf( account to - DeepLinkMapping - .generatePattern( - PlatformType.Mastodon, - account.accountKey.host, - ).toImmutableList(), + PlatformType.Mastodon.spec.deepLinkPatterns(account.accountKey.host), ) // URL containing none of the valid hosts @@ -275,29 +255,13 @@ class DeepLinkMappingTest { ImmutableMap>> = persistentMapOf( mastodonAccount to - DeepLinkMapping - .generatePattern( - PlatformType.Mastodon, - mastodonAccount.accountKey.host, - ).toImmutableList(), + PlatformType.Mastodon.spec.deepLinkPatterns(mastodonAccount.accountKey.host), misskeyAccount to - DeepLinkMapping - .generatePattern( - PlatformType.Misskey, - misskeyAccount.accountKey.host, - ).toImmutableList(), + PlatformType.Misskey.spec.deepLinkPatterns(misskeyAccount.accountKey.host), bskyAccount to - DeepLinkMapping - .generatePattern( - PlatformType.Bluesky, - bskyAccount.accountKey.host, - ).toImmutableList(), + PlatformType.Bluesky.spec.deepLinkPatterns(bskyAccount.accountKey.host), xAccount to - DeepLinkMapping - .generatePattern( - PlatformType.xQt, - xAccount.accountKey.host, - ).toImmutableList(), + PlatformType.xQt.spec.deepLinkPatterns(xAccount.accountKey.host), ) // https://mastodon.example/@alice From 7180367205cb116a6228dd7d6401ef7dd130137c Mon Sep 17 00:00:00 2001 From: Tlaster Date: Mon, 23 Mar 2026 19:59:49 +0900 Subject: [PATCH 3/6] speed up nostr request --- .../flare/ui/screen/home/GroupConfigScreen.kt | 4 +- .../flare/ui/screen/settings/EditTabDialog.kt | 6 +- .../dimension/flare/ui/component/TabIcon.kt | 4 +- .../component/status/CommonStatusComponent.kt | 3 +- .../dimension/flare/ui/icons/MisskeyIcon.kt | 76 +++++++++------- .../flare/ui/screen/home/EditTabDialog.kt | 6 +- .../data/datasource/nostr/NostrDataSource.kt | 17 +++- .../flare/data/network/nostr/NostrService.kt | 91 +++++++++++++++---- 8 files changed, 142 insertions(+), 65 deletions(-) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/GroupConfigScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/GroupConfigScreen.kt index 454f04270..dab01f083 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/GroupConfigScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/GroupConfigScreen.kt @@ -12,7 +12,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -166,7 +166,7 @@ internal fun GroupConfigScreen( modifier = Modifier.sizeIn(maxHeight = 256.dp, maxWidth = 384.dp), elevation = CardDefaults.elevatedCardElevation(defaultElevation = 3.dp), ) { - LazyHorizontalGrid(rows = GridCells.FixedSize(48.dp)) { + LazyVerticalGrid(columns = GridCells.FixedSize(48.dp)) { items(state.availableIcons) { icon -> TabIcon( accountType = AccountType.Guest, diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/EditTabDialog.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/EditTabDialog.kt index f9c7df139..fa2524451 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/EditTabDialog.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/EditTabDialog.kt @@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.AlertDialog @@ -111,8 +111,8 @@ internal fun EditTabDialog( defaultElevation = 3.dp, ), ) { - LazyHorizontalGrid( - rows = GridCells.FixedSize(48.dp), + LazyVerticalGrid( + columns = GridCells.FixedSize(48.dp), ) { items(state.availableIcons) { icon -> TabIcon( 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 ca7f368f1..8880f0af8 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 @@ -43,8 +43,8 @@ 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.platform.PlatformText -import dev.dimension.flare.ui.component.status.toImageVector import dev.dimension.flare.ui.component.platform.PlatformTextStyle +import dev.dimension.flare.ui.component.status.toImageVector import dev.dimension.flare.ui.model.onLoading import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.home.FavIconPresenter @@ -268,4 +268,4 @@ internal val TitleType.Localized.res: StringResource TitleType.Localized.LocalizedKey.AllRssFeeds -> Res.string.all_rss_feeds_title TitleType.Localized.LocalizedKey.Posts -> Res.string.posts_title TitleType.Localized.LocalizedKey.Channel -> Res.string.channel_title -} + } diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt index 3d51c9fad..54f777f9b 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt @@ -58,7 +58,6 @@ import compose.icons.fontawesomeicons.regular.CommentDots import compose.icons.fontawesomeicons.regular.Heart import compose.icons.fontawesomeicons.solid.At import compose.icons.fontawesomeicons.solid.Bell -import compose.icons.fontawesomeicons.solid.BookBookmark import compose.icons.fontawesomeicons.solid.Bookmark import compose.icons.fontawesomeicons.solid.Check import compose.icons.fontawesomeicons.solid.CircleInfo @@ -82,8 +81,8 @@ import compose.icons.fontawesomeicons.solid.RectangleList import compose.icons.fontawesomeicons.solid.Reply import compose.icons.fontawesomeicons.solid.Retweet import compose.icons.fontawesomeicons.solid.ShareNodes -import compose.icons.fontawesomeicons.solid.SquareRss import compose.icons.fontawesomeicons.solid.SquarePollHorizontal +import compose.icons.fontawesomeicons.solid.SquareRss import compose.icons.fontawesomeicons.solid.Thumbtack import compose.icons.fontawesomeicons.solid.Trash import compose.icons.fontawesomeicons.solid.Tv diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/icons/MisskeyIcon.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/icons/MisskeyIcon.kt index 4e24d038e..11819de2f 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/icons/MisskeyIcon.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/icons/MisskeyIcon.kt @@ -6,7 +6,7 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.graphics.vector.PathParser import androidx.compose.ui.graphics.vector.group import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp @@ -200,35 +200,47 @@ public val BrandsGroup.Misskey: ImageVector @HiddenFromObjC public val BrandsGroup.Nostr: ImageVector get() { - return ImageVector - .Builder( - name = "NostrIcon", - defaultWidth = 256.dp, - defaultHeight = 256.dp, - viewportWidth = 256f, - viewportHeight = 256f, - ).apply { - path( - fill = SolidColor(Color(0xFF000000)), - stroke = null, - strokeLineWidth = 0f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Miter, - strokeLineMiter = 4f, - pathFillType = PathFillType.NonZero, - pathBuilder = { - addPathNodes( - "M210.8 199.4c0 3.1-2.5 5.7-5.7 5.7h-68c-3.1 0-5.7-2.5-5.7-5." + - "7v-15.5c.3-19 2.3-37.2 6.5-45.5c2.5-5 6.7-7.7 11.5-9.1c9.1-2.7 " + - "24.9-.9 31.7-1.2c0 0 20.4.8 20.4-10.7s-9.1-8.6-9.1-8.6c-10 .3-17" + - ".7-.4-22.6-2.4c-8.3-3.3-8.6-9.2-8.6-11.2c-.4-23.1-34.5-25.9-64.5-" + - "20.1c-32.8 6.2.4 53.3.4 116.1v8.4c0 3.1-2.6 5.6-5.7 5.6H57.7c-3.1" + - " 0-5.7-2.5-5.7-5.7v-144c0-3.1 2.5-5.7 5.7-5.7h31.7c3.1 0 5.7 2.5" + - " 5.7 5.7c0 4.7 5.2 7.2 9 4.5c11.4-8.2 26-12.5 42.4-12.5c36.6 0" + - " 64.4 21.4 64.4 68.7v83.2ZM150 99.3c0-6.7-5.4-12.1-12.1-12.1s-" + - "12.1 5.4-12.1 12.1s5.4 12.1 12.1 12.1S150 106 150 99.3Z", - ) - }, - ) - }.build() + if (_nostr != null) { + return _nostr!! + } + _nostr = + ImageVector + .Builder( + name = "NostrIcon", + defaultWidth = 159.dp, + defaultHeight = 158.dp, + viewportWidth = 159f, + viewportHeight = 158f, + ).apply { + addPath( + pathData = + PathParser() + .parsePathString( + "M158.8 151.9C158.8 155 156.3 157.6 153.1 157.6H85.1C82 157.6 " + + "79.4 155.1 79.4 151.9V136.4C79.7 117.4 81.7 99.2 85.9 90.9C88.4 " + + "85.9 92.6 83.2 97.4 81.8C106.5 79.1 122.3 80.9 129.1 80.6C129.1 " + + "80.6 149.5 81.4 149.5 69.9C149.5 58.4 140.4 61.3 140.4 61.3C130.4 " + + "61.6 122.7 60.9 117.8 58.9C109.5 55.6 109.2 49.7 109.2 47.7C108.8 " + + "24.6 74.7 21.8 44.7 27.6C11.9 33.8 45.1 80.9 45.1 143.7V152.1C45.1 " + + "155.2 42.5 157.7 39.4 157.7H5.7C2.6 157.7 0 155.2 0 152V8C0 4.9 " + + "2.5 2.3 5.7 2.3H37.4C40.5 2.3 43.1 4.8 43.1 8C43.1 12.7 48.3 15.2 " + + "52.1 12.5C63.5 4.3 78.1 0 94.5 0C131.1 0 158.9 21.4 158.9 68.7V151." + + "9H158.8ZM98 51.8C98 45.1 92.6 39.7 85.9 39.7C79.2 39.7 73.8 45.1 " + + "73.8 51.8C73.8 58.5 79.2 63.9 85.9 63.9C92.6 63.9 98 58.5 98 51.8Z", + ).toNodes(), + fill = SolidColor(Color(0xFF000000)), + fillAlpha = 1.0f, + stroke = null, + strokeAlpha = 1.0f, + strokeLineWidth = 0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 4f, + pathFillType = PathFillType.NonZero, + ) + }.build() + return _nostr!! } + +@Suppress("ktlint:standard:backing-property-naming") +private var _nostr: ImageVector? = null diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/EditTabDialog.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/EditTabDialog.kt index 587d25b65..01baaf295 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/EditTabDialog.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/EditTabDialog.kt @@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable @@ -81,8 +81,8 @@ internal fun EditTabDialog( ) { FlyoutContainer( flyout = { - LazyHorizontalGrid( - rows = GridCells.FixedSize(48.dp), + LazyVerticalGrid( + columns = GridCells.FixedSize(48.dp), modifier = Modifier.heightIn(max = 120.dp), ) { items(state.availableIcons) { icon -> diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt index 45cf77bd8..c2b9d2915 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt @@ -11,6 +11,7 @@ import dev.dimension.flare.data.datasource.microblog.datasource.UserDataSource import dev.dimension.flare.data.datasource.microblog.handler.RelationHandler import dev.dimension.flare.data.datasource.microblog.handler.UserHandler import dev.dimension.flare.data.datasource.microblog.loader.RelationActionType +import dev.dimension.flare.data.datasource.microblog.paging.CacheableRemoteLoader import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader @@ -73,7 +74,9 @@ internal class NostrDataSource( get() = loader.supportedTypes override fun homeTimeline(): RemoteLoader = - object : RemoteLoader { + object : CacheableRemoteLoader { + override val pagingKey: String = "home_$accountKey" + override suspend fun load( pageSize: Int, request: PagingRequest, @@ -123,7 +126,17 @@ internal class NostrDataSource( userKey: MicroBlogKey, mediaOnly: Boolean, ): RemoteLoader = - object : RemoteLoader { + object : CacheableRemoteLoader { + override val pagingKey: String = + buildString { + append("user_timeline") + if (mediaOnly) { + append("media") + } + append(accountKey.toString()) + append(userKey.toString()) + } + override suspend fun load( pageSize: Int, request: PagingRequest, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt index 091d30e13..5d699eff6 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt @@ -33,13 +33,15 @@ import io.ktor.websocket.Frame import io.ktor.websocket.close import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonPrimitive +import kotlin.coroutines.coroutineContext import kotlin.time.Clock import kotlin.time.Instant @@ -58,6 +60,11 @@ internal object NostrService { internal const val NOSTR_HOST: String = "nostr" internal val defaultRelays: List = defaultNostrRelays + private val client by lazy { + ktorClient { + install(WebSockets) + } + } internal data class ImportedAccount( val pubkeyHex: String, @@ -292,34 +299,80 @@ internal object NostrService { relays: List, filters: List, ): List = - relays - .firstNotNullOfOrNull { relay -> - runCatching { queryRelay(relay, filters) }.getOrNull()?.takeIf { it.isNotEmpty() } - }.orEmpty() + queryRelays( + relays = relays, + filters = filters, + waitForAllRelays = false, + ) private suspend fun queryAllRelays( relays: List, filters: List, + ): List = + queryRelays( + relays = relays, + filters = filters, + waitForAllRelays = true, + ) + + private suspend fun queryRelays( + relays: List, + filters: List, + waitForAllRelays: Boolean, ): List = coroutineScope { - relays - .map { relay -> - async { - runCatching { queryRelay(relay, filters) }.getOrDefault(emptyList()) + if (relays.isEmpty()) { + return@coroutineScope emptyList() + } + val results = Channel>(Channel.UNLIMITED) + relays.forEach { relay -> + launch { + val events = runCatching { queryRelay(relay, filters) }.getOrDefault(emptyList()) + results.send(events) + } + } + + val eventsById = LinkedHashMap() + var remaining = relays.size + var hasNonEmptyResult = false + + try { + while (remaining > 0) { + val timeout = + if (hasNonEmptyResult) { + RELAY_SETTLE_TIMEOUT_MILLIS + } else { + RELAY_TIMEOUT_MILLIS + } + val result = + withTimeoutOrNull(timeout) { + results.receive() + } ?: break + remaining -= 1 + if (result.isEmpty()) { + continue + } + hasNonEmptyResult = true + result.forEach { event -> + if (event.id !in eventsById) { + eventsById[event.id] = event + } } - }.awaitAll() - .flatten() - .distinctBy { it.id } + if (!waitForAllRelays) { + break + } + } + } finally { + coroutineContext.cancelChildren() + results.close() + } + eventsById.values.toList() } private suspend fun queryRelay( relay: String, filters: List, ): List { - val client = - ktorClient { - install(WebSockets) - } val session = client.webSocketSession(urlString = relay) val subscriptionId = "flare-${Clock.System.now().toEpochMilliseconds()}" val events = mutableListOf() @@ -357,7 +410,6 @@ internal object NostrService { } finally { runCatching { session.send(Frame.Text("[\"CLOSE\",\"$subscriptionId\"]")) } runCatching { session.close() } - client.close() } } @@ -546,7 +598,8 @@ internal object NostrService { private val HEX_KEY_REGEX = Regex("^[0-9a-fA-F]{64}\$") private val HEX_DIGITS = "0123456789abcdef".toCharArray() - private const val RELAY_TIMEOUT_MILLIS = 8_000L + private const val RELAY_TIMEOUT_MILLIS = 3_500L + private const val RELAY_SETTLE_TIMEOUT_MILLIS = 350L private const val MAX_HOME_AUTHORS = 250 private const val MIN_METADATA_EVENT_LIMIT = 50 } From f406a617f3cb02d7847393c67987d83959f8ba92 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 24 Mar 2026 15:46:08 +0900 Subject: [PATCH 4/6] add compose to nostr --- .../data/database/cache/mapper/Microblog.kt | 13 +- .../data/datasource/microblog/PostEvent.kt | 55 + .../flare/data/datasource/nostr/NostrCache.kt | 47 + .../data/datasource/nostr/NostrDataSource.kt | 136 +- .../data/datasource/nostr/NostrLoader.kt | 19 +- .../flare/data/network/nostr/NostrService.kt | 1270 ++++++++++++++++- .../dev/dimension/flare/di/CommonModule.kt | 3 + .../dimension/flare/ui/model/mapper/Nostr.kt | 68 + .../ui/presenter/compose/ComposeUseCase.kt | 4 + .../data/network/nostr/NostrServiceTest.kt | 156 ++ .../nostr/NostrServiceJvmIntegrationTest.kt | 23 +- 11 files changed, 1740 insertions(+), 54 deletions(-) create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrCache.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Nostr.kt diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/Microblog.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/Microblog.kt index b9fe817de..ffe4f4cd5 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/Microblog.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/Microblog.kt @@ -187,8 +187,15 @@ private suspend fun loadChangedTimeline( private fun UiTimelineV2.usersInContent(): List = when (this) { - is UiTimelineV2.Post -> listOfNotNull(user) - is UiTimelineV2.User -> listOf(value) - is UiTimelineV2.UserList -> users + is UiTimelineV2.Post -> + listOfNotNull(user, message?.user) + + quote.flatMap { it.usersInContent() } + + parents.flatMap { it.usersInContent() } + + listOfNotNull(internalRepost).flatMap { it.usersInContent() } + is UiTimelineV2.User -> listOfNotNull(value, message?.user) + is UiTimelineV2.UserList -> + users + + listOfNotNull(message?.user) + + listOfNotNull(post).flatMap { it.usersInContent() } else -> emptyList() } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/PostEvent.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/PostEvent.kt index e3ea5f1da..cacbee1fe 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/PostEvent.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/PostEvent.kt @@ -11,6 +11,8 @@ import dev.dimension.flare.ui.model.mapper.mastodonRepost import dev.dimension.flare.ui.model.mapper.misskeyFavourite import dev.dimension.flare.ui.model.mapper.misskeyReact import dev.dimension.flare.ui.model.mapper.misskeyRenote +import dev.dimension.flare.ui.model.mapper.nostrLike +import dev.dimension.flare.ui.model.mapper.nostrRepost import dev.dimension.flare.ui.model.mapper.vvoFavorite import dev.dimension.flare.ui.model.mapper.vvoLike import dev.dimension.flare.ui.model.mapper.vvoLikeComment @@ -374,6 +376,59 @@ internal sealed interface PostEvent { ) } } + + @Serializable + sealed interface Nostr : PostEvent { + @Serializable + data class Repost( + override val postKey: MicroBlogKey, + val repostEventId: String?, + val count: Long = 0, + val accountKey: MicroBlogKey, + ) : Nostr, + UpdatePostActionMenuEvent { + override fun nextActionMenu(): ActionMenu.Item = + ActionMenu.nostrRepost( + statusKey = postKey, + repostEventId = + if (repostEventId == null) { + "" + } else { + null + }, + count = (count + if (repostEventId == null) 1 else -1).coerceAtLeast(0), + accountKey = accountKey, + ) + } + + @Serializable + data class Like( + override val postKey: MicroBlogKey, + val reactionEventId: String?, + val count: Long = 0, + val accountKey: MicroBlogKey, + ) : Nostr, + UpdatePostActionMenuEvent { + override fun nextActionMenu(): ActionMenu.Item = + ActionMenu.nostrLike( + statusKey = postKey, + reactionEventId = + if (reactionEventId == null) { + "" + } else { + null + }, + count = (count + if (reactionEventId == null) 1 else -1).coerceAtLeast(0), + accountKey = accountKey, + ) + } + + @Serializable + data class Report( + override val postKey: MicroBlogKey, + val accountKey: MicroBlogKey, + ) : Nostr + } } internal interface UpdatePostActionMenuEvent : PostEvent { diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrCache.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrCache.kt new file mode 100644 index 000000000..d0214ecf4 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrCache.kt @@ -0,0 +1,47 @@ +package dev.dimension.flare.data.datasource.nostr + +import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.network.nostr.NostrService +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.UiTimelineV2 +import kotlinx.coroutines.flow.firstOrNull + +internal interface NostrCache { + suspend fun getProfiles(pubKeys: List): Map + + suspend fun getPost( + accountKey: MicroBlogKey, + statusKey: MicroBlogKey, + ): UiTimelineV2.Post? +} + +internal class DatabaseNostrCache( + private val database: CacheDatabase, +) : NostrCache { + override suspend fun getProfiles(pubKeys: List): Map { + if (pubKeys.isEmpty()) { + return emptyMap() + } + return database + .userDao() + .findByKeys(pubKeys.distinct().map { MicroBlogKey(it, NostrService.NOSTR_HOST) }) + .firstOrNull() + .orEmpty() + .associate { it.userKey.id to it.content } + } + + override suspend fun getPost( + accountKey: MicroBlogKey, + statusKey: MicroBlogKey, + ): UiTimelineV2.Post? = + database + .statusDao() + .get( + statusKey = statusKey, + accountType = + dev.dimension.flare.model.AccountType + .Specific(accountKey), + ).firstOrNull() + ?.content as? UiTimelineV2.Post +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt index c2b9d2915..df2b24c93 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt @@ -1,13 +1,19 @@ package dev.dimension.flare.data.datasource.nostr +import dev.dimension.flare.data.datasource.microblog.ActionMenu import dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataSource import dev.dimension.flare.data.datasource.microblog.ComposeConfig import dev.dimension.flare.data.datasource.microblog.ComposeData import dev.dimension.flare.data.datasource.microblog.ComposeType +import dev.dimension.flare.data.datasource.microblog.DatabaseUpdater import dev.dimension.flare.data.datasource.microblog.NotificationFilter +import dev.dimension.flare.data.datasource.microblog.PostEvent import dev.dimension.flare.data.datasource.microblog.ProfileTab +import dev.dimension.flare.data.datasource.microblog.datasource.PostDataSource import dev.dimension.flare.data.datasource.microblog.datasource.RelationDataSource import dev.dimension.flare.data.datasource.microblog.datasource.UserDataSource +import dev.dimension.flare.data.datasource.microblog.handler.PostEventHandler +import dev.dimension.flare.data.datasource.microblog.handler.PostHandler import dev.dimension.flare.data.datasource.microblog.handler.RelationHandler import dev.dimension.flare.data.datasource.microblog.handler.UserHandler import dev.dimension.flare.data.datasource.microblog.loader.RelationActionType @@ -23,6 +29,9 @@ import dev.dimension.flare.ui.model.UiAccount import dev.dimension.flare.ui.model.UiHashtag import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.nostrLike +import dev.dimension.flare.ui.model.mapper.nostrRepost +import dev.dimension.flare.ui.presenter.compose.ComposeStatus import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.first @@ -35,6 +44,8 @@ internal class NostrDataSource( ) : AuthenticatedMicroblogDataSource, UserDataSource, RelationDataSource, + PostDataSource, + PostEventHandler.Handler, KoinComponent { private val accountRepository: AccountRepository by inject() private val loader by lazy { @@ -73,6 +84,24 @@ internal class NostrDataSource( override val supportedRelationTypes: Set get() = loader.supportedTypes + override val postHandler by lazy { + PostHandler( + accountType = + dev.dimension.flare.model.AccountType + .Specific(accountKey), + loader = loader, + ) + } + + override val postEventHandler by lazy { + PostEventHandler( + accountType = + dev.dimension.flare.model.AccountType + .Specific(accountKey), + handler = this, + ) + } + override fun homeTimeline(): RemoteLoader = object : CacheableRemoteLoader { override val pagingKey: String = "home_$accountKey" @@ -210,11 +239,116 @@ internal class NostrDataSource( override fun notification(type: NotificationFilter): RemoteLoader = notSupported() + override suspend fun handle( + event: PostEvent, + updater: DatabaseUpdater, + ) { + require(event is PostEvent.Nostr) + val credential = + accountRepository + .credentialFlow(accountKey) + .first() + .let { + if (relayHint != null && relayHint !in it.relays) { + it.copy(relays = listOf(relayHint) + it.relays) + } else { + it + } + } + when (event) { + is PostEvent.Nostr.Like -> { + if (event.reactionEventId != null) { + NostrService.deleteStatus( + credential = credential, + statusKey = MicroBlogKey(event.reactionEventId, NostrService.NOSTR_HOST), + ) + } else { + val reactionEventId = + NostrService.react( + credential = credential, + statusKey = event.postKey, + ) + updater.updateActionMenu( + event.postKey, + ActionMenu.nostrLike( + statusKey = event.postKey, + reactionEventId = reactionEventId, + count = event.count + 1, + accountKey = accountKey, + ), + ) + } + } + + is PostEvent.Nostr.Report -> + NostrService.report( + credential = credential, + statusKey = event.postKey, + ) + + is PostEvent.Nostr.Repost -> { + if (event.repostEventId != null) { + NostrService.deleteStatus( + credential = credential, + statusKey = MicroBlogKey(event.repostEventId, NostrService.NOSTR_HOST), + ) + } else { + val repostEventId = + NostrService.repost( + credential = credential, + statusKey = event.postKey, + ) + updater.updateActionMenu( + event.postKey, + ActionMenu.nostrRepost( + statusKey = event.postKey, + repostEventId = repostEventId, + count = event.count + 1, + accountKey = accountKey, + ), + ) + } + } + } + } + override suspend fun compose( data: ComposeData, progress: () -> Unit, ) { - error("Nostr compose is not implemented yet. relayHint=$relayHint") + val credential = + accountRepository + .credentialFlow(accountKey) + .first() + .let { + if (relayHint != null && relayHint !in it.relays) { + it.copy(relays = listOf(relayHint) + it.relays) + } else { + it + } + } + when (val composeStatus = data.referenceStatus?.composeStatus) { + is ComposeStatus.Quote -> + NostrService.composeQuote( + accountKey = accountKey, + credential = credential, + statusKey = composeStatus.statusKey, + content = data.content, + ) + + is ComposeStatus.Reply -> + NostrService.composeReply( + credential = credential, + statusKey = composeStatus.statusKey, + content = data.content, + ) + + null -> + NostrService.composeNote( + credential = credential, + content = data.content, + ) + } } override fun composeConfig(type: ComposeType): ComposeConfig = diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrLoader.kt index 18f220f21..8acb10a80 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrLoader.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrLoader.kt @@ -1,5 +1,6 @@ package dev.dimension.flare.data.datasource.nostr +import dev.dimension.flare.data.datasource.microblog.loader.PostLoader import dev.dimension.flare.data.datasource.microblog.loader.RelationActionType import dev.dimension.flare.data.datasource.microblog.loader.RelationLoader import dev.dimension.flare.data.datasource.microblog.loader.UserLoader @@ -9,12 +10,14 @@ import dev.dimension.flare.ui.model.UiAccount import dev.dimension.flare.ui.model.UiHandle import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiRelation +import dev.dimension.flare.ui.model.UiTimelineV2 internal class NostrLoader( private val accountKey: MicroBlogKey, private val credentialProvider: suspend () -> UiAccount.Nostr.Credential, ) : UserLoader, - RelationLoader { + RelationLoader, + PostLoader { override val supportedTypes: Set = emptySet() override suspend fun userByHandleAndHost(uiHandle: UiHandle): UiProfile = @@ -37,6 +40,20 @@ internal class NostrLoader( targetPubkey = userKey.id, ) + override suspend fun status(statusKey: MicroBlogKey): UiTimelineV2 = + NostrService.loadStatus( + credential = credentialProvider(), + accountKey = accountKey, + statusKey = statusKey, + ) + + override suspend fun deleteStatus(statusKey: MicroBlogKey) { + NostrService.deleteStatus( + credential = credentialProvider(), + statusKey = statusKey, + ) + } + override suspend fun follow(userKey: MicroBlogKey): Unit = throw UnsupportedOperationException("Nostr follow is not implemented yet") override suspend fun unfollow(userKey: MicroBlogKey): Unit = diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt index 5d699eff6..78a630847 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt @@ -1,47 +1,80 @@ package dev.dimension.flare.data.network.nostr +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip18Reposts.quotes.QAddressableTag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag +import com.vitorpamplona.quartz.nip18Reposts.quotes.toQTagArray import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip19Bech32.toNsec +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip56Reports.ReportType +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import dev.dimension.flare.common.JSON +import dev.dimension.flare.data.datasource.microblog.ActionMenu +import dev.dimension.flare.data.datasource.nostr.NostrCache import dev.dimension.flare.data.network.ktorClient import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.model.ReferenceType import dev.dimension.flare.ui.model.ClickEvent import dev.dimension.flare.ui.model.UiAccount import dev.dimension.flare.ui.model.UiHandle +import dev.dimension.flare.ui.model.UiIcon +import dev.dimension.flare.ui.model.UiMedia import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.nostrLike +import dev.dimension.flare.ui.model.mapper.nostrRepost +import dev.dimension.flare.ui.render.RenderContent +import dev.dimension.flare.ui.render.RenderRun +import dev.dimension.flare.ui.render.RenderTextStyle import dev.dimension.flare.ui.render.toUi import dev.dimension.flare.ui.render.toUiPlainText +import dev.dimension.flare.ui.render.uiRichTextOf import dev.dimension.flare.ui.route.DeeplinkRoute import dev.whyoleg.cryptography.random.CryptographyRandom +import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.plugins.websocket.webSocketSession import io.ktor.websocket.Frame import io.ktor.websocket.close import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonPrimitive -import kotlin.coroutines.coroutineContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import kotlin.time.Clock import kotlin.time.Instant @@ -56,10 +89,11 @@ public val defaultNostrRelays: List = "wss://purplepag.es", ) -internal object NostrService { +internal object NostrService : KoinComponent { internal const val NOSTR_HOST: String = "nostr" internal val defaultRelays: List = defaultNostrRelays + private val cache: NostrCache by inject() private val client by lazy { ktorClient { install(WebSockets) @@ -140,25 +174,39 @@ internal object NostrService { listOf( Filter( authors = authors, - kinds = listOf(TextNoteEvent.KIND), + kinds = timelineEventKinds, until = until, limit = pageSize, ), ), - ).filterIsInstance() + minEventsBeforeReturn = pageSize.coerceAtMost(MIN_EARLY_RETURN_EVENTS).coerceAtLeast(1), + ).filter(::isTimelineRootEvent) .sortedByDescending { it.createdAt } if (events.isEmpty()) { return emptyList() } - val metadata = loadMetadata(relays, events.map { it.pubKey }.distinct()) - return events.map { event -> - event.toUi( + val eventGraph = loadEventGraph(relays = relays, roots = events) + val interactionStats = + loadInteractionStats( + relays = relays, + accountPubkey = credential.pubkey, + targetEventIds = eventGraph.keys.toList(), + ) + + val profiles = + loadProfiles( + relays = relays, + pubKeys = eventGraph.values.map { it.pubKey }.distinct(), accountKey = accountKey, - metadata = metadata[event.pubKey], ) - } + return events.toUiTimeline( + accountKey = accountKey, + profiles = profiles, + eventsById = eventGraph, + interactionStats = interactionStats, + ) } internal suspend fun loadProfile( @@ -198,23 +246,68 @@ internal object NostrService { listOf( Filter( authors = listOf(targetPubkey), - kinds = listOf(TextNoteEvent.KIND), + kinds = timelineEventKinds, until = until, limit = pageSize, ), ), - ).filterIsInstance() + ).filter(::isTimelineRootEvent) .sortedByDescending { it.createdAt } if (events.isEmpty()) { return emptyList() } - val metadata = loadMetadata(relays, listOf(targetPubkey)) - return events.map { event -> - event.toUi( + val eventGraph = loadEventGraph(relays = relays, roots = events) + val interactionStats = + loadInteractionStats( + relays = relays, + accountPubkey = credential.pubkey, + targetEventIds = eventGraph.keys.toList(), + ) + val profiles = + loadProfiles( + relays = relays, + pubKeys = eventGraph.values.map { it.pubKey }.distinct(), accountKey = accountKey, - metadata = metadata[event.pubKey], ) - } + return events.toUiTimeline( + accountKey = accountKey, + profiles = profiles, + eventsById = eventGraph, + interactionStats = interactionStats, + ) + } + + internal suspend fun loadStatus( + credential: UiAccount.Nostr.Credential, + accountKey: MicroBlogKey, + statusKey: MicroBlogKey, + ): UiTimelineV2 { + val relays = credential.relays.ifEmpty { defaultRelays } + val event = + loadEvent( + relays = relays, + statusKey = statusKey, + ) ?: error("Nostr status not found: $statusKey") + val eventGraph = loadEventGraph(relays = relays, roots = listOf(event)) + val interactionStats = + loadInteractionStats( + relays = relays, + accountPubkey = credential.pubkey, + targetEventIds = eventGraph.keys.toList(), + ) + val profiles = + loadProfiles( + relays = relays, + pubKeys = eventGraph.values.map { it.pubKey }.distinct(), + accountKey = accountKey, + ) + return listOf(event) + .toUiTimeline( + accountKey = accountKey, + profiles = profiles, + eventsById = eventGraph, + interactionStats = interactionStats, + ).first() } internal suspend fun relation( @@ -241,6 +334,160 @@ internal object NostrService { ) } + internal suspend fun composeNote( + credential: UiAccount.Nostr.Credential, + content: String, + ): String { + val imported = exportAccount(credential) + val event = + signEvent( + pubkeyHex = imported.pubkeyHex, + secretKey = requireNotNull(imported.nsec), + template = TextNoteEvent.build(content), + ) + publishEvent(credential, event) + return event.id + } + + internal suspend fun composeReply( + credential: UiAccount.Nostr.Credential, + statusKey: MicroBlogKey, + content: String, + ): String { + val relays = credential.relays.ifEmpty { defaultRelays } + val target = + loadEvent(relays = relays, statusKey = statusKey) + ?: error("Reply target not found: $statusKey") + val imported = exportAccount(credential) + val template = + if (target is TextNoteEvent) { + TextNoteEvent.build( + note = content, + replyingTo = EventHintBundle(target), + ) + } else { + TextNoteEvent.build(content) { + add(ETag(target.id).toTagArray()) + pTag(target.pubKey) + } + } + val event = + signEvent( + pubkeyHex = imported.pubkeyHex, + secretKey = requireNotNull(imported.nsec), + template = template, + ) + publishEvent(credential, event) + return event.id + } + + internal suspend fun composeQuote( + accountKey: MicroBlogKey, + credential: UiAccount.Nostr.Credential, + statusKey: MicroBlogKey, + content: String, + ): String { + val relays = credential.relays.ifEmpty { defaultRelays } + val target = + loadEvent(relays = relays, statusKey = statusKey) + val cachedPost = target?.let { null } ?: cache.getPost(accountKey = accountKey, statusKey = statusKey) + val quoteTag = + quoteTagArray( + target = target, + statusKey = statusKey, + relayHint = relays.firstNotNullOfOrNull(RelayUrlNormalizer::normalizeOrNull), + cachedAuthorPubKey = cachedPost?.user?.key?.id, + ) ?: error("Quote target not found: $statusKey") + val authorPubKey = target?.pubKey ?: cachedPost?.user?.key?.id ?: error("Quote target not found: $statusKey") + val imported = exportAccount(credential) + val event = + signEvent( + pubkeyHex = imported.pubkeyHex, + secretKey = requireNotNull(imported.nsec), + template = + TextNoteEvent.build(content) { + add(quoteTag) + pTag(authorPubKey) + }, + ) + publishEvent(credential, event) + return event.id + } + + internal suspend fun repost( + credential: UiAccount.Nostr.Credential, + statusKey: MicroBlogKey, + ): String { + val relays = credential.relays.ifEmpty { defaultRelays } + val target = + loadEvent(relays = relays, statusKey = statusKey) + ?: error("Repost target not found: $statusKey") + val imported = exportAccount(credential) + val event = + signEvent( + pubkeyHex = imported.pubkeyHex, + secretKey = requireNotNull(imported.nsec), + template = GenericRepostEvent.build(target, null, null), + ) + publishEvent(credential, event) + return event.id + } + + internal suspend fun react( + credential: UiAccount.Nostr.Credential, + statusKey: MicroBlogKey, + ): String { + val relays = credential.relays.ifEmpty { defaultRelays } + val target = + loadEvent(relays = relays, statusKey = statusKey) + ?: error("Reaction target not found: $statusKey") + val imported = exportAccount(credential) + val event = + signEvent( + pubkeyHex = imported.pubkeyHex, + secretKey = requireNotNull(imported.nsec), + template = ReactionEvent.like(EventHintBundle(target)), + ) + publishEvent(credential, event) + return event.id + } + + internal suspend fun report( + credential: UiAccount.Nostr.Credential, + statusKey: MicroBlogKey, + ) { + val relays = credential.relays.ifEmpty { defaultRelays } + val target = + loadEvent(relays = relays, statusKey = statusKey) + ?: error("Report target not found: $statusKey") + val imported = exportAccount(credential) + val event = + signEvent( + pubkeyHex = imported.pubkeyHex, + secretKey = requireNotNull(imported.nsec), + template = ReportEvent.build(target, ReportType.SPAM), + ) + publishEvent(credential, event) + } + + internal suspend fun deleteStatus( + credential: UiAccount.Nostr.Credential, + statusKey: MicroBlogKey, + ) { + val relays = credential.relays.ifEmpty { defaultRelays } + val target = + loadEvent(relays = relays, statusKey = statusKey) + ?: error("Delete target not found: $statusKey") + val imported = exportAccount(credential) + val deletionEvent = + signEvent( + pubkeyHex = imported.pubkeyHex, + secretKey = requireNotNull(imported.nsec), + template = DeletionEvent.build(listOf(target)), + ) + publishEvent(credential, deletionEvent) + } + private suspend fun loadAuthors( relays: List, accountPubkey: String, @@ -271,7 +518,7 @@ internal object NostrService { if (authors.isEmpty()) { return emptyMap() } - val latestByPubkey = + val eventsByPubkey = queryAllRelays( relays = relays, filters = @@ -284,25 +531,63 @@ internal object NostrService { ), ).filterIsInstance() .groupBy { it.pubKey } - .mapValues { (_, values) -> values.maxByOrNull { it.createdAt } } return buildMap { - latestByPubkey.forEach { (pubkey, event) -> - event?.runCatching { contactMetaData() }?.getOrNull()?.let { + eventsByPubkey.forEach { (pubkey, events) -> + resolveMetadata(events)?.let { put(pubkey, it) } } } } + internal fun resolveMetadata(events: List): UserMetadata? = + events + .sortedByDescending { it.createdAt } + .firstNotNullOfOrNull { event -> + runCatching { event.contactMetaData() }.getOrNull() + } + + private suspend fun loadProfiles( + relays: List, + pubKeys: List, + accountKey: MicroBlogKey, + ): Map { + if (pubKeys.isEmpty()) { + return emptyMap() + } + val cachedProfiles = cache.getProfiles(pubKeys) + + val missingPubKeys = pubKeys.distinct().filterNot { it in cachedProfiles } + if (missingPubKeys.isEmpty()) { + return cachedProfiles + } + + val fetchedProfiles = + loadMetadata(relays, missingPubKeys) + .let { metadata -> + missingPubKeys.associateWith { pubKey -> + profileOf( + pubKey = pubKey, + metadata = metadata[pubKey], + accountKey = accountKey, + ) + } + } + + return cachedProfiles + fetchedProfiles + } + private suspend fun queryFirstRelay( relays: List, filters: List, + minEventsBeforeReturn: Int = MIN_EARLY_RETURN_EVENTS, ): List = queryRelays( relays = relays, filters = filters, waitForAllRelays = false, + minEventsBeforeReturn = minEventsBeforeReturn, ) private suspend fun queryAllRelays( @@ -313,12 +598,14 @@ internal object NostrService { relays = relays, filters = filters, waitForAllRelays = true, + minEventsBeforeReturn = null, ) private suspend fun queryRelays( relays: List, filters: List, waitForAllRelays: Boolean, + minEventsBeforeReturn: Int?, ): List = coroutineScope { if (relays.isEmpty()) { @@ -327,7 +614,10 @@ internal object NostrService { val results = Channel>(Channel.UNLIMITED) relays.forEach { relay -> launch { - val events = runCatching { queryRelay(relay, filters) }.getOrDefault(emptyList()) + val events = + withTimeoutOrNull(RELAY_TIMEOUT_MILLIS + RELAY_SETTLE_TIMEOUT_MILLIS) { + runCatching { queryRelay(relay, filters) }.getOrDefault(emptyList()) + } ?: emptyList() results.send(events) } } @@ -338,16 +628,20 @@ internal object NostrService { try { while (remaining > 0) { - val timeout = - if (hasNonEmptyResult) { - RELAY_SETTLE_TIMEOUT_MILLIS - } else { - RELAY_TIMEOUT_MILLIS - } val result = - withTimeoutOrNull(timeout) { + if (waitForAllRelays) { results.receive() - } ?: break + } else { + val timeout = + if (hasNonEmptyResult) { + RELAY_SETTLE_TIMEOUT_MILLIS + } else { + RELAY_TIMEOUT_MILLIS + } + withTimeoutOrNull(timeout) { + results.receive() + } ?: break + } remaining -= 1 if (result.isEmpty()) { continue @@ -359,7 +653,9 @@ internal object NostrService { } } if (!waitForAllRelays) { - break + if (eventsById.size >= (minEventsBeforeReturn ?: 1)) { + break + } } } } finally { @@ -398,7 +694,7 @@ internal object NostrService { when (val message = parseRelayMessage(frame.data.decodeToString())) { RelayMessage.EndOfStoredEvents -> break is RelayMessage.EventEnvelope -> events += message.event - null -> Unit + else -> Unit } } @@ -413,6 +709,111 @@ internal object NostrService { } } + private const val MIN_EARLY_RETURN_EVENTS = 4 + + private suspend fun publishEvent( + credential: UiAccount.Nostr.Credential, + event: Event, + ) { + val relays = credential.relays.ifEmpty { defaultRelays } + val requiredSuccessCount = minOf(PUBLISH_SUCCESS_QUORUM, relays.size) + if (requiredSuccessCount == 0) { + return + } + supervisorScope { + val results = Channel>(Channel.UNLIMITED) + relays.forEach { relay -> + launch { + results.send( + runCatching { + publishEventToRelay( + relay = relay, + event = event, + ) + }, + ) + } + } + var successCount = 0 + var failureCount = 0 + val failures = mutableListOf() + try { + repeat(relays.size) { + val result = results.receive() + result + .onSuccess { + successCount += 1 + if (successCount >= requiredSuccessCount) { + return@supervisorScope + } + }.onFailure { + failureCount += 1 + failures += it + val remainingRelays = relays.size - successCount - failureCount + if (successCount + remainingRelays < requiredSuccessCount) { + throw PublishToRelayException( + requiredSuccessCount = requiredSuccessCount, + successCount = successCount, + failures = failures.toList(), + ) + } + } + } + } finally { + coroutineContext.cancelChildren() + results.close() + } + } + } + + private suspend fun publishEventToRelay( + relay: String, + event: Event, + ) { + val session = client.webSocketSession(urlString = relay) + try { + session.send( + Frame.Text( + buildString { + append("[\"EVENT\",") + append(event.toJson()) + append("]") + }, + ), + ) + val ack = + awaitRelayAck( + session = session, + eventId = event.id, + ) ?: throw IllegalStateException("Timed out waiting for relay OK: $relay") + if (!ack.accepted) { + throw IllegalStateException("Relay rejected event: $relay${ack.message?.let { " ($it)" } ?: ""}") + } + } finally { + runCatching { session.close() } + } + } + + private suspend fun awaitRelayAck( + session: DefaultClientWebSocketSession, + eventId: String, + ): RelayMessage.OkEnvelope? = + withTimeoutOrNull(RELAY_PUBLISH_TIMEOUT_MILLIS) { + while (true) { + when (val frame = session.incoming.receive()) { + is Frame.Text -> { + val message = parseRelayMessage(frame.data.decodeToString()) + if (message is RelayMessage.OkEnvelope && message.eventId == eventId) { + return@withTimeoutOrNull message + } + } + + else -> Unit + } + } + null + } + private fun normalizeRelays(relayInput: String): List { val candidates = relayInput @@ -428,6 +829,16 @@ internal object NostrService { }.distinct() } + private const val PUBLISH_SUCCESS_QUORUM = 3 + + private class PublishToRelayException( + requiredSuccessCount: Int, + successCount: Int, + val failures: List, + ) : Exception( + "Failed to publish event to enough relays: $successCount/$requiredSuccessCount succeeded.", + ) + private fun normalizeSecret(raw: String): NSec { val value = raw.removePrefix("nostr:").trim() return when { @@ -466,12 +877,14 @@ internal object NostrService { pubkeyHex: String, secretKey: String, template: EventTemplate, - ): T = - template.sign( + ): T { + val normalizedSecretHex = normalizeSecret(secretKey).hex + return template.sign( pubKey = pubkeyHex, - privKey = NSec(secretKey.removePrefix("nostr:")).hex.hexToByteArray(), + privKey = normalizedSecretHex.hexToByteArray(), pubKeyByteArray = pubkeyHex.hexToByteArray(), ) + } private fun EventTemplate.sign( pubKey: String, @@ -500,6 +913,15 @@ internal object NostrService { } "EOSE" -> RelayMessage.EndOfStoredEvents + "OK" -> + payload.getOrNull(1)?.jsonPrimitive?.contentOrNull?.let { eventId -> + RelayMessage.OkEnvelope( + eventId = eventId, + accepted = payload.getOrNull(2)?.jsonPrimitive?.booleanOrNull == true, + message = payload.getOrNull(3)?.jsonPrimitive?.contentOrNull, + ) + } + else -> null } }.getOrNull() @@ -514,35 +936,684 @@ internal object NostrService { return chars.concatToString() } + private suspend fun loadEvent( + relays: List, + statusKey: MicroBlogKey, + ): Event? = + queryAllRelays( + relays = relays, + filters = listOf(Filter(ids = listOf(statusKey.id))), + ).maxByOrNull { it.createdAt } + + internal fun quoteTagArray( + target: Event?, + statusKey: MicroBlogKey, + relayHint: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl?, + cachedAuthorPubKey: String?, + ): Array? = + when { + target != null -> EventHintBundle(target).toQTagArray() + cachedAuthorPubKey != null && statusKey.id.length == 64 -> QEventTag.assemble(statusKey.id, relayHint, cachedAuthorPubKey) + else -> null + } + + private suspend fun loadInteractionStats( + relays: List, + accountPubkey: String, + targetEventIds: List, + ): Map { + val distinctIds = targetEventIds.distinct() + if (distinctIds.isEmpty()) { + return emptyMap() + } + + val interactions = + distinctIds + .chunked(MAX_EVENT_ID_BATCH) + .flatMap { ids -> + queryAllRelays( + relays = relays, + filters = + listOf( + Filter( + kinds = listOf(ReactionEvent.KIND, RepostEvent.KIND, GenericRepostEvent.KIND), + tags = mapOf("e" to ids), + limit = maxOf(ids.size * 20, 200), + ), + ), + ) + }.distinctBy { it.id } + + if (interactions.isEmpty()) { + return emptyMap() + } + + return interactions.fold(mutableMapOf()) { acc, event -> + val targetId = + when (event) { + is ReactionEvent -> event.originalPost().lastOrNull() + is RepostEvent -> event.boostedEventId() + is GenericRepostEvent -> event.boostedEventId() + else -> null + } ?: return@fold acc + + if (targetId !in distinctIds) { + return@fold acc + } + + val current = acc[targetId] ?: InteractionStats() + acc[targetId] = + when (event) { + is ReactionEvent -> + if (event.content == ReactionEvent.LIKE || event.content.isBlank()) { + current.copy( + reactionCount = current.reactionCount + 1, + myReactionEventId = + if (event.pubKey == accountPubkey) { + event.id + } else { + current.myReactionEventId + }, + ) + } else { + current + } + + is RepostEvent -> + current.copy( + repostCount = current.repostCount + 1, + myRepostEventId = + if (event.pubKey == accountPubkey) { + event.id + } else { + current.myRepostEventId + }, + ) + + is GenericRepostEvent -> + current.copy( + repostCount = current.repostCount + 1, + myRepostEventId = + if (event.pubKey == accountPubkey) { + event.id + } else { + current.myRepostEventId + }, + ) + + else -> current + } + acc + } + } + + internal fun List.toUiTimeline( + accountKey: MicroBlogKey, + profiles: Map, + eventsById: Map, + interactionStats: Map = emptyMap(), + ): List { + val cache = mutableMapOf() + + fun resolve( + event: Event, + visited: Set, + ): UiTimelineV2.Post? { + if (event.id in visited) { + return null + } + cache[event.id]?.let { return it } + val nextVisited = visited + event.id + val resolved = + when (event) { + is TextNoteEvent -> + event.toUi( + accountKey = accountKey, + profile = profiles[event.pubKey] ?: profileOf(event.pubKey, null, accountKey), + eventsById = eventsById, + profiles = profiles, + interactionStats = interactionStats, + visited = nextVisited, + resolveEvent = ::resolve, + ) + is RepostEvent -> + event.toUiRepost( + accountKey = accountKey, + profiles = profiles, + eventsById = eventsById, + visited = nextVisited, + resolveEvent = ::resolve, + ) + is GenericRepostEvent -> + event.toUiGenericRepost( + accountKey = accountKey, + profiles = profiles, + eventsById = eventsById, + visited = nextVisited, + resolveEvent = ::resolve, + ) + else -> null + } + if (resolved != null) { + cache[event.id] = resolved + } + return resolved + } + + return mapNotNull { event -> + resolve(event, emptySet()) + } + } + private fun TextNoteEvent.toUi( accountKey: MicroBlogKey, - metadata: UserMetadata?, - ): UiTimelineV2.Post = - UiTimelineV2.Post( + profile: UiProfile, + eventsById: Map, + profiles: Map, + interactionStats: Map, + visited: Set, + resolveEvent: (Event, Set) -> UiTimelineV2.Post?, + ): UiTimelineV2.Post { + val statusKey = MicroBlogKey(id, NOSTR_HOST) + val stats = interactionStats[id] ?: InteractionStats() + return UiTimelineV2.Post( message = null, platformType = PlatformType.Nostr, - images = persistentListOf(), + images = mediaFromTags().toImmutableList(), sensitive = false, contentWarning = null, - user = profileOf(pubKey, metadata, accountKey), - quote = persistentListOf(), + user = profile, content = content.toUiPlainText(), - actions = persistentListOf(), + actions = + buildList { + add( + ActionMenu.Item( + icon = UiIcon.Reply, + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.Reply), + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.Compose.Reply( + accountKey = accountKey, + statusKey = statusKey, + ), + ), + ), + ) + add( + ActionMenu.Group( + displayItem = + ActionMenu.nostrRepost( + statusKey = statusKey, + repostEventId = stats.myRepostEventId, + count = stats.repostCount, + accountKey = accountKey, + ), + actions = + listOf( + ActionMenu.nostrRepost( + statusKey = statusKey, + repostEventId = stats.myRepostEventId, + count = stats.repostCount, + accountKey = accountKey, + ), + ActionMenu.Item( + icon = UiIcon.Quote, + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.Quote), + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.Compose.Quote( + accountKey = accountKey, + statusKey = statusKey, + ), + ), + ), + ).toImmutableList(), + ), + ) + add( + ActionMenu.nostrLike( + statusKey = statusKey, + reactionEventId = stats.myReactionEventId, + count = stats.reactionCount, + accountKey = accountKey, + ), + ) + add( + ActionMenu.Group( + displayItem = + ActionMenu.Item( + icon = UiIcon.More, + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.More), + ), + actions = + listOf( + if (pubKey == accountKey.id) { + ActionMenu.Item( + icon = UiIcon.Delete, + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.Delete), + color = ActionMenu.Item.Color.Red, + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.Status.DeleteConfirm( + accountType = AccountType.Specific(accountKey), + statusKey = statusKey, + ), + ), + ) + } else { + ActionMenu.Item( + icon = UiIcon.Report, + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.Report), + color = ActionMenu.Item.Color.Red, + clickEvent = + ClickEvent.event(accountKey) { + dev.dimension.flare.data.datasource.microblog.PostEvent.Nostr.Report( + postKey = statusKey, + accountKey = accountKey, + ) + }, + ) + }, + ).toImmutableList(), + ), + ) + }.toImmutableList(), poll = null, - statusKey = MicroBlogKey(id, NOSTR_HOST), + statusKey = statusKey, card = null, createdAt = Instant.fromEpochSeconds(createdAt).toUi(), emojiReactions = persistentListOf(), sourceChannel = null, - visibility = UiTimelineV2.Post.Visibility.Public, + visibility = null, replyToHandle = null, - references = persistentListOf(), - parents = persistentListOf(), + references = + ( + parentEventIds().map { + UiTimelineV2.Post.Reference( + statusKey = MicroBlogKey(it, NOSTR_HOST), + type = ReferenceType.Reply, + ) + } + + quoteEventIds() + .map { + UiTimelineV2.Post.Reference( + statusKey = MicroBlogKey(it, NOSTR_HOST), + type = ReferenceType.Quote, + ) + } + ).distinctBy { it.type to it.statusKey } + .toImmutableList(), + parents = + parentEventIds() + .mapNotNull { parentId -> + parentId + .takeUnless { it in visited } + ?.let(eventsById::get) + ?.let { resolveEvent(it, visited) } + }.toImmutableList(), + quote = + ( + quoteEventIds() + .mapNotNull { quoteId -> + quoteId + .takeUnless { it in visited } + ?.let(eventsById::get) + ?.let { resolveEvent(it, visited) } + } + + quoteAddressReferences() + .mapNotNull { address -> + resolveAddressReference(address, eventsById, visited, resolveEvent) + } + ).distinctBy { it.statusKey } + .toImmutableList(), internalRepost = null, - clickEvent = ClickEvent.Noop, + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.Status.Detail( + statusKey = statusKey, + accountType = AccountType.Specific(accountKey), + ), + ), extraKey = null, accountType = AccountType.Specific(accountKey), ) + } + + private fun TextNoteEvent.parentEventIds(): List { + val rootIds = tags.mapNotNull(MarkedETag::parseRootId) + val replyIds = tags.mapNotNull(MarkedETag::parseReply).map { it.eventId } + val positionalIds = + if (rootIds.isEmpty() && replyIds.isEmpty()) { + tags.mapNotNull(MarkedETag::parseOnlyPositionalThreadTagsIds) + } else { + emptyList() + } + return (rootIds + replyIds + positionalIds).distinct() + } + + private fun TextNoteEvent.quoteEventIds(): List = tags.mapNotNull(QEventTag::parse).map { it.eventId }.distinct() + + private fun TextNoteEvent.quoteAddressReferences(): List
= + tags + .mapNotNull(QAddressableTag::parse) + .map { it.address } + .distinctBy { it.toValue() } + + private fun TextNoteEvent.mediaFromTags(): List { + val mediaFromIMeta = + tags + .mapNotNull(IMetaTag::parse) + .flatten() + .mapNotNull(::toUiMedia) + + val urlsFromR = + tags + .filter { it.size > 1 && it[0] == "r" } + .map { it[1] } + .filter(::looksLikeMediaUrl) + .filterNot { url -> mediaFromIMeta.any { it.url == url } } + .mapNotNull { url -> toUiMedia(url = url, dimensions = null, description = null) } + + return (mediaFromIMeta + urlsFromR).distinctBy { it.url } + } + + private fun toUiMedia(iMeta: IMetaTag): UiMedia? = + toUiMedia( + url = iMeta.url, + dimensions = iMeta.properties["dim"]?.firstOrNull()?.let(DimensionTag::parse), + description = iMeta.properties["alt"]?.firstOrNull(), + ) + + private fun toUiMedia( + url: String, + dimensions: DimensionTag?, + description: String?, + ): UiMedia? { + val width = dimensions?.width?.toFloat() ?: 0f + val height = dimensions?.height?.toFloat() ?: 0f + return when { + isVideoUrl(url) -> + UiMedia.Video( + url = url, + thumbnailUrl = url, + description = description, + height = height, + width = width, + ) + isGifUrl(url) -> + UiMedia.Gif( + url = url, + previewUrl = url, + description = description, + height = height, + width = width, + ) + isAudioUrl(url) -> + UiMedia.Audio( + url = url, + description = description, + previewUrl = null, + ) + isImageUrl(url) -> + UiMedia.Image( + url = url, + previewUrl = url, + description = description, + height = height, + width = width, + sensitive = false, + ) + else -> + UiMedia.Image( + url = url, + previewUrl = url, + description = description, + height = height, + width = width, + sensitive = false, + ) + } + } + + private fun looksLikeMediaUrl(url: String): Boolean = isImageUrl(url) || isVideoUrl(url) || isGifUrl(url) || isAudioUrl(url) + + private fun isImageUrl(url: String): Boolean = + url.substringBefore('?').lowercase().let { + it.endsWith(".jpg") || it.endsWith(".jpeg") || it.endsWith(".png") || it.endsWith(".webp") || it.endsWith(".heic") + } + + private fun isVideoUrl(url: String): Boolean = + url.substringBefore('?').lowercase().let { + it.endsWith(".mp4") || it.endsWith(".webm") || it.endsWith(".mov") || it.endsWith(".m4v") + } + + private fun isGifUrl(url: String): Boolean = url.substringBefore('?').lowercase().endsWith(".gif") + + private fun isAudioUrl(url: String): Boolean = + url.substringBefore('?').lowercase().let { + it.endsWith(".mp3") || it.endsWith(".m4a") || it.endsWith(".aac") || it.endsWith(".wav") || it.endsWith(".ogg") + } + + private fun RepostEvent.toUiRepost( + accountKey: MicroBlogKey, + profiles: Map, + eventsById: Map, + visited: Set, + resolveEvent: (Event, Set) -> UiTimelineV2.Post?, + ): UiTimelineV2.Post? { + val boostedEvent = resolvedBoostedEvent(eventsById) + val boostedPost = boostedEvent?.let { resolveEvent(it, visited) } ?: return null + return boostedPost.copy( + message = + repostMessage( + accountKey = accountKey, + actor = profiles[pubKey] ?: profileOf(pubKey, null, accountKey), + statusKey = MicroBlogKey(id, NOSTR_HOST), + createdAt = createdAt, + ), + statusKey = MicroBlogKey(id, NOSTR_HOST), + internalRepost = boostedPost, + ) + } + + private fun GenericRepostEvent.toUiGenericRepost( + accountKey: MicroBlogKey, + profiles: Map, + eventsById: Map, + visited: Set, + resolveEvent: (Event, Set) -> UiTimelineV2.Post?, + ): UiTimelineV2.Post? { + val boostedEvent = resolvedBoostedEvent(eventsById) + val boostedPost = boostedEvent?.let { resolveEvent(it, visited) } ?: return null + return boostedPost.copy( + message = + repostMessage( + accountKey = accountKey, + actor = profiles[pubKey] ?: profileOf(pubKey, null, accountKey), + statusKey = MicroBlogKey(id, NOSTR_HOST), + createdAt = createdAt, + ), + statusKey = MicroBlogKey(id, NOSTR_HOST), + internalRepost = boostedPost, + ) + } + + private fun RepostEvent.resolvedBoostedEvent(eventsById: Map): Event? = + containedPost() + ?: boostedEventId()?.let(eventsById::get) + ?: boostedAddress()?.let { address -> + eventsById.values + .filter { it.addressValue() == address.toValue() } + .maxByOrNull { it.createdAt } + } + + private fun GenericRepostEvent.resolvedBoostedEvent(eventsById: Map): Event? = + containedPost() + ?: boostedEventId()?.let(eventsById::get) + ?: boostedAddress()?.let { address -> + eventsById.values + .filter { it.addressValue() == address.toValue() } + .maxByOrNull { it.createdAt } + } + + private fun repostMessage( + accountKey: MicroBlogKey, + actor: UiProfile, + statusKey: MicroBlogKey, + createdAt: Long, + ): UiTimelineV2.Message = + UiTimelineV2.Message( + user = actor, + statusKey = statusKey, + icon = UiIcon.Retweet, + type = UiTimelineV2.Message.Type.Localized(UiTimelineV2.Message.Type.Localized.MessageId.Repost), + createdAt = Instant.fromEpochSeconds(createdAt).toUi(), + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.Profile.User( + accountType = AccountType.Specific(accountKey), + userKey = actor.key, + ), + ), + accountType = AccountType.Specific(accountKey), + ) + + private fun resolveAddressReference( + address: Address, + eventsById: Map, + visited: Set, + resolveEvent: (Event, Set) -> UiTimelineV2.Post?, + ): UiTimelineV2.Post? = + eventsById.values + .filter { it.addressValue() == address.toValue() } + .maxByOrNull { it.createdAt } + ?.let { resolveEvent(it, visited) } + + private suspend fun loadEventGraph( + relays: List, + roots: List, + ): Map { + val eventsById = LinkedHashMap() + val pendingEventIds = LinkedHashSet() + val pendingAddresses = LinkedHashMap() + + fun register(event: Event) { + if (eventsById.containsKey(event.id)) { + return + } + eventsById[event.id] = event + embeddedEvents(event).forEach(::register) + referencedEventIds(event) + .filterNot(eventsById::containsKey) + .forEach(pendingEventIds::add) + referencedAddresses(event).forEach { address -> + val addressValue = address.toValue() + if (!pendingAddresses.containsKey(addressValue)) { + pendingAddresses[addressValue] = address + } + } + } + + roots.forEach(::register) + + repeat(MAX_REFERENCE_FETCH_ROUNDS) { + val eventIdsToFetch = pendingEventIds.filterNot(eventsById::containsKey).take(MAX_EVENT_ID_BATCH) + val addressesToFetch = + pendingAddresses.values + .filter { address -> + eventsById.values.none { it.addressValue() == address.toValue() } + }.take(MAX_ADDRESS_FILTER_BATCH) + + if (eventIdsToFetch.isEmpty() && addressesToFetch.isEmpty()) { + return@repeat + } + + eventIdsToFetch.forEach(pendingEventIds::remove) + addressesToFetch.forEach { pendingAddresses.remove(it.toValue()) } + + val fetchedEvents = + fetchEventsByIds(relays, eventIdsToFetch) + + fetchEventsByAddress(relays, addressesToFetch) + + if (fetchedEvents.isEmpty()) { + return@repeat + } + + fetchedEvents.forEach(::register) + } + + return eventsById + } + + private suspend fun fetchEventsByIds( + relays: List, + eventIds: List, + ): List = + if (eventIds.isEmpty()) { + emptyList() + } else { + eventIds + .chunked(MAX_EVENT_ID_BATCH) + .flatMap { ids -> + queryAllRelays( + relays = relays, + filters = listOf(Filter(ids = ids)), + ) + }.distinctBy { it.id } + } + + private suspend fun fetchEventsByAddress( + relays: List, + addresses: List
, + ): List { + if (addresses.isEmpty()) { + return emptyList() + } + val filters = + addresses.map { address -> + Filter( + authors = listOf(address.pubKeyHex), + kinds = listOf(address.kind), + tags = mapOf("d" to listOf(address.dTag)), + limit = 1, + ) + } + return queryAllRelays( + relays = relays, + filters = filters, + ).groupBy { it.addressValue() } + .mapNotNull { (_, values) -> values.maxByOrNull { it.createdAt } } + } + + private fun referencedEventIds(event: Event): List = + when (event) { + is TextNoteEvent -> event.parentEventIds() + event.quoteEventIds() + is RepostEvent -> listOfNotNull(event.boostedEventId()) + is GenericRepostEvent -> listOfNotNull(event.boostedEventId()) + else -> emptyList() + }.distinct() + + private fun referencedAddresses(event: Event): List
= + when (event) { + is TextNoteEvent -> event.quoteAddressReferences() + is RepostEvent -> listOfNotNull(event.boostedAddress()) + is GenericRepostEvent -> listOfNotNull(event.boostedAddress()) + else -> emptyList() + }.distinctBy { it.toValue() } + + private fun embeddedEvents(event: Event): List = + when (event) { + is RepostEvent -> listOfNotNull(event.containedPost()) + is GenericRepostEvent -> listOfNotNull(event.containedPost()) + else -> emptyList() + } + + private fun Event.addressValue(): String = Address.assemble(kind = kind, pubKeyHex = pubKey, dTag = dTag()) + + private fun isTimelineRootEvent(event: Event): Boolean = + event is TextNoteEvent || + event is RepostEvent || + event is GenericRepostEvent private fun profileOf( pubKey: String, @@ -551,11 +1622,15 @@ internal object NostrService { ): UiProfile { val bestName = metadata?.bestName().orEmpty() val npub = NPub.Companion.create(pubKey) + val handleRaw = + metadata?.name?.takeIf { it.isNotBlank() } + ?: metadata?.nip05?.substringBefore("@")?.takeIf { it.isNotBlank() } + ?: bestName.ifBlank { npub.take(16) } return UiProfile( key = MicroBlogKey(pubKey, NOSTR_HOST), handle = UiHandle( - raw = bestName.ifBlank { npub.take(16) }, + raw = handleRaw, host = NOSTR_HOST, ), avatar = metadata?.picture.orEmpty(), @@ -578,28 +1653,127 @@ internal object NostrService { ), mark = listOfNotNull( + if (metadata?.nip05Verified == true) { + UiProfile.Mark.Verified + } else { + null + }, if (metadata?.bot == true) { UiProfile.Mark.Bot } else { null }, ).toImmutableList(), - bottomContent = null, + bottomContent = metadata.toBottomContent(), ) } + private fun UserMetadata?.toBottomContent(): UiProfile.BottomContent? { + val fields = + this + ?.let { metadata -> + buildMap { + metadata.pronouns?.takeIf { it.isNotBlank() }?.let { + put("Pronouns", it.toUiPlainText()) + } + metadata.website?.takeIf { it.isNotBlank() }?.let { + put("Website", externalLink(display = it, target = it)) + } + metadata.nip05?.takeIf { it.isNotBlank() }?.let { + put("NIP-05", it.toUiPlainText()) + } + metadata.lud16?.takeIf { it.isNotBlank() }?.let { + put("LUD16", it.toUiPlainText()) + } + metadata.lud06?.takeIf { it.isNotBlank() }?.let { + put("LUD06", it.toUiPlainText()) + } + metadata.domain?.takeIf { it.isNotBlank() }?.let { + put("Domain", externalLink(display = it, target = it)) + } + metadata.twitter?.takeIf { it.isNotBlank() }?.let { + val handle = it.removePrefix("@") + put( + "Twitter", + externalLink( + display = "@$handle", + target = "https://x.com/$handle", + ), + ) + } + metadata.tags + ?.lists + ?.mapNotNull { tag -> + tag + .takeIf { it.isNotEmpty() } + ?.joinToString(separator = " / ") + ?.takeIf { it.isNotBlank() } + }?.toList() + ?.takeIf { it.isNotEmpty() } + ?.let { + put("Tags", it.joinToString(separator = "\n").toUiPlainText()) + } + } + }?.takeIf { it.isNotEmpty() } + ?.toImmutableMap() + return fields?.let(UiProfile.BottomContent::Fields) + } + + private fun externalLink( + display: String, + target: String, + ) = uiRichTextOf( + renderRuns = + listOf( + RenderContent.Text( + runs = + listOf( + RenderRun.Text( + text = display, + style = RenderTextStyle(link = target.toHttpsUrl()), + ), + ).toImmutableList(), + ), + ), + ) + + private fun String.toHttpsUrl(): String = + if (startsWith("http://") || startsWith("https://")) { + this + } else { + "https://$this" + } + private sealed interface RelayMessage { data class EventEnvelope( val event: Event, ) : RelayMessage + data class OkEnvelope( + val eventId: String, + val accepted: Boolean, + val message: String?, + ) : RelayMessage + data object EndOfStoredEvents : RelayMessage } + internal data class InteractionStats( + val reactionCount: Long = 0, + val repostCount: Long = 0, + val myReactionEventId: String? = null, + val myRepostEventId: String? = null, + ) + private val HEX_KEY_REGEX = Regex("^[0-9a-fA-F]{64}\$") private val HEX_DIGITS = "0123456789abcdef".toCharArray() + private val timelineEventKinds = listOf(TextNoteEvent.KIND, RepostEvent.KIND, GenericRepostEvent.KIND) + private const val RELAY_PUBLISH_TIMEOUT_MILLIS = 1_500L private const val RELAY_TIMEOUT_MILLIS = 3_500L private const val RELAY_SETTLE_TIMEOUT_MILLIS = 350L + private const val MAX_REFERENCE_FETCH_ROUNDS = 4 + private const val MAX_EVENT_ID_BATCH = 100 + private const val MAX_ADDRESS_FILTER_BATCH = 32 private const val MAX_HOME_AUTHORS = 250 private const val MIN_METADATA_EVENT_LIMIT = 50 } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt index db6ce625a..e1544196d 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt @@ -2,6 +2,8 @@ package dev.dimension.flare.di import dev.dimension.flare.data.database.provideAppDatabase import dev.dimension.flare.data.database.provideCacheDatabase +import dev.dimension.flare.data.datasource.nostr.DatabaseNostrCache +import dev.dimension.flare.data.datasource.nostr.NostrCache import dev.dimension.flare.data.network.ai.OpenAIService import dev.dimension.flare.data.network.rss.Readability import dev.dimension.flare.data.repository.AccountRepository @@ -26,6 +28,7 @@ internal val commonModule = singleOf(::AccountRepository) singleOf(::provideAppDatabase) singleOf(::provideCacheDatabase) + single { DatabaseNostrCache(get()) } singleOf(::ApplicationRepository) single { DraftMediaStore(get()) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Nostr.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Nostr.kt new file mode 100644 index 000000000..226968bac --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Nostr.kt @@ -0,0 +1,68 @@ +package dev.dimension.flare.ui.model.mapper + +import dev.dimension.flare.data.datasource.microblog.ActionMenu +import dev.dimension.flare.data.datasource.microblog.PostEvent +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.ClickEvent +import dev.dimension.flare.ui.model.UiIcon +import dev.dimension.flare.ui.model.UiNumber + +internal fun ActionMenu.Companion.nostrRepost( + statusKey: MicroBlogKey, + repostEventId: String?, + count: Long, + accountKey: MicroBlogKey, +): ActionMenu.Item = + ActionMenu.Item( + updateKey = "nostr_repost_$statusKey", + icon = if (repostEventId != null) UiIcon.Unretweet else UiIcon.Retweet, + text = + ActionMenu.Item.Text.Localized( + if (repostEventId != null) { + ActionMenu.Item.Text.Localized.Type.Unretweet + } else { + ActionMenu.Item.Text.Localized.Type.Retweet + }, + ), + count = UiNumber(count), + color = if (repostEventId != null) ActionMenu.Item.Color.PrimaryColor else null, + clickEvent = + ClickEvent.event(accountKey) { + PostEvent.Nostr.Repost( + postKey = statusKey, + repostEventId = repostEventId, + count = count, + accountKey = accountKey, + ) + }, + ) + +internal fun ActionMenu.Companion.nostrLike( + statusKey: MicroBlogKey, + reactionEventId: String?, + count: Long, + accountKey: MicroBlogKey, +): ActionMenu.Item = + ActionMenu.Item( + updateKey = "nostr_like_$statusKey", + icon = if (reactionEventId != null) UiIcon.Unlike else UiIcon.Like, + text = + ActionMenu.Item.Text.Localized( + if (reactionEventId != null) { + ActionMenu.Item.Text.Localized.Type.Unlike + } else { + ActionMenu.Item.Text.Localized.Type.Like + }, + ), + count = UiNumber(count), + color = if (reactionEventId != null) ActionMenu.Item.Color.Red else null, + clickEvent = + ClickEvent.event(accountKey) { + PostEvent.Nostr.Like( + postKey = statusKey, + reactionEventId = reactionEventId, + count = count, + accountKey = accountKey, + ) + }, + ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposeUseCase.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposeUseCase.kt index b89657f5e..184e2e3b5 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposeUseCase.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposeUseCase.kt @@ -5,6 +5,7 @@ import dev.dimension.flare.common.InAppNotification import dev.dimension.flare.common.Message import dev.dimension.flare.data.datasource.microblog.ComposeData import dev.dimension.flare.data.datastore.AppDataStore +import dev.dimension.flare.data.repository.DebugRepository import dev.dimension.flare.data.repository.newDraftGroupId import dev.dimension.flare.data.repository.toComposeDraftBundle import dev.dimension.flare.data.repository.tryRun @@ -27,6 +28,9 @@ internal class ComposeUseCase( groupId: String, ) { invoke(accounts = accounts, data = data, groupId = groupId) { + if (it is ComposeProgressState.Error) { + DebugRepository.error(it.throwable) + } withContext(Dispatchers.Main) { when (it) { is ComposeProgressState.Error -> diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/network/nostr/NostrServiceTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/network/nostr/NostrServiceTest.kt index fa9f45db4..445a6c835 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/network/nostr/NostrServiceTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/network/nostr/NostrServiceTest.kt @@ -1,14 +1,46 @@ package dev.dimension.flare.data.network.nostr +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import dev.dimension.flare.common.TestFormatter +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.ReferenceType +import dev.dimension.flare.ui.humanizer.PlatformFormatter import dev.dimension.flare.ui.model.UiAccount +import dev.dimension.flare.ui.model.UiMedia +import dev.dimension.flare.ui.model.UiTimelineV2 +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertTrue class NostrServiceTest { + @BeforeTest + fun setUp() { + stopKoin() + startKoin { + modules( + module { + single { TestFormatter() } + }, + ) + } + } + + @AfterTest + fun tearDown() { + stopKoin() + } + @Test fun generateAccountCreatesMatchingPrivateAndPublicKeys() { val generated = NostrService.generateAccount(relayInput = "") @@ -70,7 +102,131 @@ class NostrServiceTest { assertEquals(account.npub, NPub.create(account.pubkeyHex)) } + @Test + fun timelineMapsParentsMediaQuoteAndRepost() { + val events = + listOf( + ROOT_EVENT_JSON, + REPLY_EVENT_JSON, + QUOTE_EVENT_JSON, + REPOST_EVENT_JSON, + ).map { + Event.fromJson(it) + } + + val eventGraph = events.associateBy { it.id } + val timeline = + NostrService.run { + events.toUiTimeline( + accountKey = MicroBlogKey("nostr-test", NostrService.NOSTR_HOST), + profiles = emptyMap(), + eventsById = eventGraph, + ) + } + + val root = timeline.first { it.statusKey.id == ROOT_EVENT_ID } + assertEquals(1, root.images.size) + val rootImage = assertIs(root.images.first()) + assertEquals("https://image.nostr.build/0e2f4411fcc7be6fdc0ef68f2ee58d24f8cdcea0e1475299555c5321e4f4fd02.jpg", rootImage.url) + assertEquals(1440f, rootImage.width) + assertEquals(1080f, rootImage.height) + + val reply = timeline.first { it.statusKey.id == REPLY_EVENT_ID } + assertEquals(listOf(ROOT_EVENT_ID), reply.parents.map { it.statusKey.id }) + assertEquals(listOf(ROOT_EVENT_ID), reply.references.filter { it.type == ReferenceType.Reply }.map { it.statusKey.id }) + assertEquals( + 1, + reply.parents + .first() + .images.size, + ) + + val quote = timeline.first { it.statusKey.id == QUOTE_EVENT_ID } + assertEquals(listOf(ROOT_EVENT_ID), quote.quote.map { it.statusKey.id }) + assertEquals(listOf(ROOT_EVENT_ID), quote.references.filter { it.type == ReferenceType.Quote }.map { it.statusKey.id }) + + val repost = timeline.first { it.statusKey.id == REPOST_EVENT_ID } + assertNotNull(repost.internalRepost) + assertEquals(ROOT_EVENT_ID, repost.internalRepost.statusKey.id) + assertNotNull(repost.message) + assertEquals( + UiTimelineV2.Message.Type.Localized.MessageId.Repost, + assertIs(repost.message.type).data, + ) + } + + @Test + fun quoteTagArrayUsesEventWhenAvailable() { + val target = Event.fromJson(ROOT_EVENT_JSON) + + val tag = + NostrService.quoteTagArray( + target = target, + statusKey = MicroBlogKey(ROOT_EVENT_ID, NostrService.NOSTR_HOST), + relayHint = RelayUrlNormalizer.normalizeOrNull("wss://relay.damus.io"), + cachedAuthorPubKey = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + ) + + assertContentEquals( + arrayOf("q", ROOT_EVENT_ID, "", target.pubKey), + tag, + ) + } + + @Test + fun quoteTagArrayFallsBackToCachedAuthorWhenTargetMissing() { + val tag = + NostrService.quoteTagArray( + target = null, + statusKey = MicroBlogKey(ROOT_EVENT_ID, NostrService.NOSTR_HOST), + relayHint = RelayUrlNormalizer.normalizeOrNull("wss://relay.damus.io"), + cachedAuthorPubKey = "0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac", + ) + + assertContentEquals( + arrayOf( + "q", + ROOT_EVENT_ID, + "wss://relay.damus.io/", + "0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac", + ), + tag, + ) + } + + @Test + fun resolveMetadataFallsBackToOlderParsableEvent() { + val metadata = + NostrService.resolveMetadata( + listOf( + Event.fromJson(INVALID_LATEST_METADATA_EVENT_JSON) as MetadataEvent, + Event.fromJson(VALID_OLDER_METADATA_EVENT_JSON) as MetadataEvent, + ), + ) + + assertNotNull(metadata) + assertEquals("alice", metadata.name) + assertEquals("https://example.com/avatar.png", metadata.picture) + } + private companion object { const val SECRET_KEY_HEX = "1111111111111111111111111111111111111111111111111111111111111111" + const val ROOT_EVENT_ID = "1b14014e85b5a3f554dc92198ce118d83562147ca08a98e4bb07b00d003108f7" + const val ROOT_EVENT_PUBKEY = "0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac" + const val REPLY_EVENT_ID = "b355c1f0b68a9162cc466d3602ad6b93ec05993aeade4c7edc1f6eca8e3ae23d" + const val QUOTE_EVENT_ID = "c355c1f0b68a9162cc466d3602ad6b93ec05993aeade4c7edc1f6eca8e3ae23e" + const val REPOST_EVENT_ID = "d355c1f0b68a9162cc466d3602ad6b93ec05993aeade4c7edc1f6eca8e3ae23f" + const val VALID_OLDER_METADATA_EVENT_JSON = + """{"id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","pubkey":"$ROOT_EVENT_PUBKEY","created_at":1754503000,"kind":0,"tags":[],"content":"{\"name\":\"alice\",\"picture\":\"https://example.com/avatar.png\"}","sig":"25524fbbf5c22e0d4f953bd8688e753bb9f36efd93b907e4dc34dc30256ddc365924ecf7529dc33e49b84d4b744f5f98b6f97c586bf63b5c0dc5c26f215f7580"}""" + const val INVALID_LATEST_METADATA_EVENT_JSON = + """{"id":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","pubkey":"$ROOT_EVENT_PUBKEY","created_at":1754504000,"kind":0,"tags":[],"content":"{","sig":"35524fbbf5c22e0d4f953bd8688e753bb9f36efd93b907e4dc34dc30256ddc365924ecf7529dc33e49b84d4b744f5f98b6f97c586bf63b5c0dc5c26f215f7580"}""" + val ROOT_EVENT_JSON = + """{"id":"1b14014e85b5a3f554dc92198ce118d83562147ca08a98e4bb07b00d003108f7","pubkey":"$ROOT_EVENT_PUBKEY","created_at":1754504013,"kind":1,"tags":[["imeta","url https://image.nostr.build/0e2f4411fcc7be6fdc0ef68f2ee58d24f8cdcea0e1475299555c5321e4f4fd02.jpg","dim 1440x1080"],["r","https://image.nostr.build/0e2f4411fcc7be6fdc0ef68f2ee58d24f8cdcea0e1475299555c5321e4f4fd02.jpg"]],"content":"test","sig":"25524fbbf5c22e0d4f953bd8688e753bb9f36efd93b907e4dc34dc30256ddc365924ecf7529dc33e49b84d4b744f5f98b6f97c586bf63b5c0dc5c26f215f7580"}""" + val REPLY_EVENT_JSON = + """{"id":"b355c1f0b68a9162cc466d3602ad6b93ec05993aeade4c7edc1f6eca8e3ae23d","pubkey":"$ROOT_EVENT_PUBKEY","created_at":1754504024,"kind":1,"tags":[["e","1b14014e85b5a3f554dc92198ce118d83562147ca08a98e4bb07b00d003108f7","","root"]],"content":"reply","sig":"d7dcdd1617c5a2bb7788476cd3499de0437bea86129b30eb41d3c7749fec2603ec3fc042ce3f867917069af36b6fa50daf008c82379cc726e04ce1c82003e645"}""" + val QUOTE_EVENT_JSON = + """{"id":"c355c1f0b68a9162cc466d3602ad6b93ec05993aeade4c7edc1f6eca8e3ae23e","pubkey":"$ROOT_EVENT_PUBKEY","created_at":1754504030,"kind":1,"tags":[["q","1b14014e85b5a3f554dc92198ce118d83562147ca08a98e4bb07b00d003108f7"]],"content":"quoting root","sig":"e7dcdd1617c5a2bb7788476cd3499de0437bea86129b30eb41d3c7749fec2603ec3fc042ce3f867917069af36b6fa50daf008c82379cc726e04ce1c82003e646"}""" + val REPOST_EVENT_JSON = + """{"id":"d355c1f0b68a9162cc466d3602ad6b93ec05993aeade4c7edc1f6eca8e3ae23f","pubkey":"$ROOT_EVENT_PUBKEY","created_at":1754504040,"kind":6,"tags":[["p","$ROOT_EVENT_PUBKEY"],["e","1b14014e85b5a3f554dc92198ce118d83562147ca08a98e4bb07b00d003108f7"]],"content":"","sig":"f7dcdd1617c5a2bb7788476cd3499de0437bea86129b30eb41d3c7749fec2603ec3fc042ce3f867917069af36b6fa50daf008c82379cc726e04ce1c82003e647"}""" } } diff --git a/shared/src/jvmTest/kotlin/dev/dimension/flare/data/network/nostr/NostrServiceJvmIntegrationTest.kt b/shared/src/jvmTest/kotlin/dev/dimension/flare/data/network/nostr/NostrServiceJvmIntegrationTest.kt index 3f0187632..2505aa30a 100644 --- a/shared/src/jvmTest/kotlin/dev/dimension/flare/data/network/nostr/NostrServiceJvmIntegrationTest.kt +++ b/shared/src/jvmTest/kotlin/dev/dimension/flare/data/network/nostr/NostrServiceJvmIntegrationTest.kt @@ -1,11 +1,19 @@ package dev.dimension.flare.data.network.nostr +import androidx.room.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import dev.dimension.flare.RobolectricTest import dev.dimension.flare.common.TestFormatter +import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.datasource.nostr.DatabaseNostrCache +import dev.dimension.flare.data.datasource.nostr.NostrCache +import dev.dimension.flare.memoryDatabaseBuilder import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType import dev.dimension.flare.ui.humanizer.PlatformFormatter import dev.dimension.flare.ui.model.UiAccount import dev.dimension.flare.ui.model.UiTimelineV2 +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.koin.core.context.startKoin @@ -20,13 +28,25 @@ import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue -class NostrServiceJvmIntegrationTest { +class NostrServiceJvmIntegrationTest : RobolectricTest() { + private lateinit var database: CacheDatabase + private lateinit var cache: DatabaseNostrCache + @BeforeTest fun setUp() { + database = + Room + .memoryDatabaseBuilder() + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.Unconfined) + .build() + cache = DatabaseNostrCache(database) stopKoin() startKoin { modules( module { + single { database } + single { cache } single { TestFormatter() } }, ) @@ -35,6 +55,7 @@ class NostrServiceJvmIntegrationTest { @AfterTest fun tearDown() { + database.close() stopKoin() } From 1a9b772d85cf4a708cea5cba6f6dbb91c5bbaad4 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 24 Mar 2026 16:06:03 +0900 Subject: [PATCH 5/6] add more features for nostr --- .../data/datasource/nostr/NostrDataSource.kt | 160 ++++- .../nostr/StatusDetailRemoteMediator.kt | 64 ++ .../flare/data/network/nostr/NostrService.kt | 591 ++++++++++++++++++ 3 files changed, 810 insertions(+), 5 deletions(-) create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/StatusDetailRemoteMediator.kt diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt index df2b24c93..bb0f0270f 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt @@ -66,7 +66,11 @@ internal class NostrDataSource( ) } - override val supportedNotificationFilter: List = emptyList() + override val supportedNotificationFilter: List = + listOf( + NotificationFilter.All, + NotificationFilter.Mention, + ) override val userHandler by lazy { UserHandler( host = NostrService.NOSTR_HOST, @@ -213,11 +217,109 @@ internal class NostrDataSource( } } - override fun context(statusKey: MicroBlogKey): RemoteLoader = notSupported() + override fun context(statusKey: MicroBlogKey): RemoteLoader = + StatusDetailRemoteMediator( + statusKey = statusKey, + accountKey = accountKey, + credentialProvider = { + accountRepository + .credentialFlow(accountKey) + .first() + .let { + if (relayHint != null && relayHint !in it.relays) { + it.copy(relays = listOf(relayHint) + it.relays) + } else { + it + } + } + }, + ) + + override fun searchStatus(query: String): RemoteLoader = + object : CacheableRemoteLoader { + override val pagingKey: String = "search_status_${accountKey}_$query" + + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend) { + return PagingResult(endOfPaginationReached = true) + } + val credential = + accountRepository + .credentialFlow(accountKey) + .first() + .let { + if (relayHint != null && relayHint !in it.relays) { + it.copy(relays = listOf(relayHint) + it.relays) + } else { + it + } + } + val until = + when (request) { + PagingRequest.Refresh -> null + is PagingRequest.Append -> request.nextKey.toLongOrNull() + is PagingRequest.Prepend -> null + } + val data = + NostrService.searchStatus( + credential = credential, + accountKey = accountKey, + query = query, + pageSize = pageSize, + until = until, + ) + val nextKey = + data + .filterIsInstance() + .minOfOrNull { it.createdAt.value.epochSeconds - 1 } + ?.takeIf { data.isNotEmpty() } + ?.toString() + return PagingResult( + endOfPaginationReached = data.isEmpty(), + data = data, + nextKey = nextKey, + ) + } + } - override fun searchStatus(query: String): RemoteLoader = notSupported() + override fun searchUser(query: String): RemoteLoader = + object : CacheableRemoteLoader { + override val pagingKey: String = "search_user_${accountKey}_$query" - override fun searchUser(query: String): RemoteLoader = notSupported() + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend || request is PagingRequest.Append) { + return PagingResult(endOfPaginationReached = true) + } + val credential = + accountRepository + .credentialFlow(accountKey) + .first() + .let { + if (relayHint != null && relayHint !in it.relays) { + it.copy(relays = listOf(relayHint) + it.relays) + } else { + it + } + } + val data = + NostrService.searchUser( + credential = credential, + accountKey = accountKey, + query = query, + pageSize = pageSize, + ) + return PagingResult( + endOfPaginationReached = true, + data = data, + ) + } + } override fun discoverUsers(): RemoteLoader = notSupported() @@ -237,7 +339,55 @@ internal class NostrDataSource( ), ) - override fun notification(type: NotificationFilter): RemoteLoader = notSupported() + override fun notification(type: NotificationFilter): RemoteLoader = + object : CacheableRemoteLoader { + override val pagingKey: String = "notification_${type.name.lowercase()}_$accountKey" + + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend) { + return PagingResult(endOfPaginationReached = true) + } + val credential = + accountRepository + .credentialFlow(accountKey) + .first() + .let { + if (relayHint != null && relayHint !in it.relays) { + it.copy(relays = listOf(relayHint) + it.relays) + } else { + it + } + } + val until = + when (request) { + PagingRequest.Refresh -> null + is PagingRequest.Append -> request.nextKey.toLongOrNull() + is PagingRequest.Prepend -> null + } + val data = + NostrService.loadNotifications( + credential = credential, + accountKey = accountKey, + pageSize = pageSize, + until = until, + type = type, + ) + val nextKey = + data + .filterIsInstance() + .minOfOrNull { it.createdAt.value.epochSeconds - 1 } + ?.takeIf { data.isNotEmpty() } + ?.toString() + return PagingResult( + endOfPaginationReached = data.isEmpty(), + data = data, + nextKey = nextKey, + ) + } + } override suspend fun handle( event: PostEvent, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/StatusDetailRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/StatusDetailRemoteMediator.kt new file mode 100644 index 000000000..67736bd18 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/StatusDetailRemoteMediator.kt @@ -0,0 +1,64 @@ +package dev.dimension.flare.data.datasource.nostr + +import androidx.paging.ExperimentalPagingApi +import dev.dimension.flare.data.datasource.microblog.paging.CacheableRemoteLoader +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.network.nostr.NostrService +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiAccount +import dev.dimension.flare.ui.model.UiTimelineV2 + +@OptIn(ExperimentalPagingApi::class) +internal class StatusDetailRemoteMediator( + private val statusKey: MicroBlogKey, + private val accountKey: MicroBlogKey, + private val credentialProvider: suspend () -> UiAccount.Nostr.Credential, +) : CacheableRemoteLoader { + override val pagingKey: String = + buildString { + append("status_detail_") + append(statusKey.toString()) + append("_") + append(accountKey.toString()) + } + + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + val credential = credentialProvider() + return when (request) { + PagingRequest.Refresh -> + PagingResult( + endOfPaginationReached = false, + data = + listOf( + NostrService.loadStatus( + credential = credential, + accountKey = accountKey, + statusKey = statusKey, + ), + ), + nextKey = pagingKey, + ) + + is PagingRequest.Append -> + PagingResult( + endOfPaginationReached = true, + data = + NostrService.loadStatusContext( + credential = credential, + accountKey = accountKey, + statusKey = statusKey, + pageSize = pageSize, + ), + ) + + is PagingRequest.Prepend -> + PagingResult( + endOfPaginationReached = true, + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt index 78a630847..b426956a2 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt @@ -22,6 +22,9 @@ import com.vitorpamplona.quartz.nip18Reposts.quotes.QAddressableTag import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag import com.vitorpamplona.quartz.nip18Reposts.quotes.toQTagArray import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip19Bech32.toNsec @@ -277,6 +280,182 @@ internal object NostrService : KoinComponent { ) } + internal suspend fun searchStatus( + credential: UiAccount.Nostr.Credential, + accountKey: MicroBlogKey, + query: String, + pageSize: Int, + until: Long?, + ): List { + val normalizedQuery = query.trim() + if (normalizedQuery.isEmpty()) { + return emptyList() + } + val relays = credential.relays.ifEmpty { defaultRelays } + parseSearchStatusEventId(normalizedQuery)?.let { eventId -> + return runCatching { + listOf( + loadStatus( + credential = credential, + accountKey = accountKey, + statusKey = MicroBlogKey(eventId, NOSTR_HOST), + ), + ) + }.getOrDefault(emptyList()) + } + + val events = + queryAllRelays( + relays = relays, + filters = + listOf( + Filter( + kinds = listOf(TextNoteEvent.KIND), + until = until, + limit = pageSize, + search = normalizedQuery, + ), + ), + ).filter(::isTimelineRootEvent) + .filter { event -> + event is TextNoteEvent && event.content.contains(normalizedQuery, ignoreCase = true) + }.sortedByDescending { it.createdAt } + .take(pageSize) + if (events.isEmpty()) { + return emptyList() + } + + val eventGraph = loadEventGraph(relays = relays, roots = events) + val interactionStats = + loadInteractionStats( + relays = relays, + accountPubkey = credential.pubkey, + targetEventIds = eventGraph.keys.toList(), + ) + val profiles = + loadProfiles( + relays = relays, + pubKeys = eventGraph.values.map { it.pubKey }.distinct(), + accountKey = accountKey, + ) + return events.toUiTimeline( + accountKey = accountKey, + profiles = profiles, + eventsById = eventGraph, + interactionStats = interactionStats, + ) + } + + internal suspend fun searchUser( + credential: UiAccount.Nostr.Credential, + accountKey: MicroBlogKey, + query: String, + pageSize: Int, + ): List { + val normalizedQuery = query.trim() + if (normalizedQuery.isEmpty()) { + return emptyList() + } + val relays = credential.relays.ifEmpty { defaultRelays } + parseSearchProfilePubkey(normalizedQuery)?.let { pubkey -> + return listOf( + loadProfile( + credential = credential, + accountKey = accountKey, + targetPubkey = pubkey, + ), + ) + } + + val metadataEvents = + queryAllRelays( + relays = relays, + filters = + listOf( + Filter( + kinds = listOf(MetadataEvent.KIND), + limit = maxOf(pageSize * 4, MIN_METADATA_EVENT_LIMIT), + search = normalizedQuery, + ), + ), + ).filterIsInstance() + .groupBy { it.pubKey } + + if (metadataEvents.isEmpty()) { + return emptyList() + } + + return metadataEvents + .mapNotNull { (pubkey, events) -> + val metadata = resolveMetadata(events) ?: return@mapNotNull null + if (!metadata.matchesSearchQuery(normalizedQuery)) { + return@mapNotNull null + } + profileOf( + pubKey = pubkey, + metadata = metadata, + accountKey = accountKey, + ) + }.sortedBy { profile -> + profile.name.raw.indexOf(normalizedQuery, ignoreCase = true).let { + if (it >= 0) { + it + } else { + Int.MAX_VALUE + } + } + }.take(pageSize) + } + + internal suspend fun loadNotifications( + credential: UiAccount.Nostr.Credential, + accountKey: MicroBlogKey, + pageSize: Int, + until: Long?, + type: dev.dimension.flare.data.datasource.microblog.NotificationFilter, + ): List { + val relays = credential.relays.ifEmpty { defaultRelays } + val events = + notificationFilters( + accountPubkey = credential.pubkey, + pageSize = pageSize, + until = until, + type = type, + ).flatMap { filter -> + queryAllRelays( + relays = relays, + filters = listOf(filter), + ) + }.distinctBy { it.id } + .filterNot { it.pubKey == credential.pubkey } + .sortedByDescending { it.createdAt } + .take(pageSize) + if (events.isEmpty()) { + return emptyList() + } + + val eventGraph = loadEventGraph(relays = relays, roots = events) + val interactionStats = + loadInteractionStats( + relays = relays, + accountPubkey = credential.pubkey, + targetEventIds = eventGraph.keys.toList(), + ) + val profiles = + loadProfiles( + relays = relays, + pubKeys = eventGraph.values.map { it.pubKey }.distinct(), + accountKey = accountKey, + ) + return events.toUiNotifications( + accountKey = accountKey, + accountPubkey = credential.pubkey, + profiles = profiles, + eventsById = eventGraph, + interactionStats = interactionStats, + ) + } + internal suspend fun loadStatus( credential: UiAccount.Nostr.Credential, accountKey: MicroBlogKey, @@ -310,6 +489,53 @@ internal object NostrService : KoinComponent { ).first() } + internal suspend fun loadStatusContext( + credential: UiAccount.Nostr.Credential, + accountKey: MicroBlogKey, + statusKey: MicroBlogKey, + pageSize: Int, + ): List { + val relays = credential.relays.ifEmpty { defaultRelays } + val event = + loadEvent( + relays = relays, + statusKey = statusKey, + ) ?: error("Nostr status not found: $statusKey") + + val ancestorEvents = buildAncestorChain(event = event, relays = relays) + val replyEvents = + loadDirectReplies( + relays = relays, + statusKey = statusKey, + pageSize = pageSize, + ) + val threadEvents = (ancestorEvents + event + replyEvents).distinctBy(Event::id) + if (threadEvents.isEmpty()) { + return emptyList() + } + + val eventGraph = loadEventGraph(relays = relays, roots = threadEvents) + val interactionStats = + loadInteractionStats( + relays = relays, + accountPubkey = credential.pubkey, + targetEventIds = eventGraph.keys.toList(), + ) + val profiles = + loadProfiles( + relays = relays, + pubKeys = eventGraph.values.map { it.pubKey }.distinct(), + accountKey = accountKey, + ) + + return threadEvents.toUiTimeline( + accountKey = accountKey, + profiles = profiles, + eventsById = eventGraph, + interactionStats = interactionStats, + ) + } + internal suspend fun relation( credential: UiAccount.Nostr.Credential, targetPubkey: String, @@ -873,6 +1099,36 @@ internal object NostrService : KoinComponent { } } + private fun parseSearchProfilePubkey(raw: String): String? { + val value = raw.removePrefix("nostr:").trim() + return when { + value.startsWith("npub1", ignoreCase = true) -> + (Nip19Parser.parseAll(value).singleOrNull() as? NPub)?.hex + + value.startsWith("nprofile1", ignoreCase = true) -> + (Nip19Parser.parseAll(value).singleOrNull() as? NProfile)?.hex + + HEX_KEY_REGEX.matches(value) -> value.lowercase() + + else -> null + } + } + + private fun parseSearchStatusEventId(raw: String): String? { + val value = raw.removePrefix("nostr:").trim() + return when { + value.startsWith("note1", ignoreCase = true) -> + (Nip19Parser.parseAll(value).singleOrNull() as? NNote)?.hex + + value.startsWith("nevent1", ignoreCase = true) -> + (Nip19Parser.parseAll(value).singleOrNull() as? NEvent)?.hex + + HEX_KEY_REGEX.matches(value) -> value.lowercase() + + else -> null + } + } + private fun signEvent( pubkeyHex: String, secretKey: String, @@ -945,6 +1201,49 @@ internal object NostrService : KoinComponent { filters = listOf(Filter(ids = listOf(statusKey.id))), ).maxByOrNull { it.createdAt } + private suspend fun buildAncestorChain( + event: Event, + relays: List, + ): List { + val chain = mutableListOf() + var current: Event? = event + val visited = mutableSetOf() + while (current is TextNoteEvent) { + val parentId = current.immediateParentEventId() ?: break + if (!visited.add(parentId)) { + break + } + val parent = + loadEvent( + relays = relays, + statusKey = MicroBlogKey(parentId, NOSTR_HOST), + ) ?: break + chain += parent + current = parent + } + return chain.reversed() + } + + private suspend fun loadDirectReplies( + relays: List, + statusKey: MicroBlogKey, + pageSize: Int, + ): List = + queryAllRelays( + relays = relays, + filters = + listOf( + Filter( + kinds = listOf(TextNoteEvent.KIND), + tags = mapOf("e" to listOf(statusKey.id)), + limit = maxOf(pageSize * 3, pageSize, 20), + ), + ), + ).filterIsInstance() + .filter { it.isDirectReplyTo(statusKey.id) } + .sortedBy(Event::createdAt) + .take(pageSize) + internal fun quoteTagArray( target: Event?, statusKey: MicroBlogKey, @@ -1105,6 +1404,72 @@ internal object NostrService : KoinComponent { } } + private fun List.toUiNotifications( + accountKey: MicroBlogKey, + accountPubkey: String, + profiles: Map, + eventsById: Map, + interactionStats: Map = emptyMap(), + ): List { + val cache = mutableMapOf() + + fun resolve( + event: Event, + visited: Set, + ): UiTimelineV2.Post? { + if (event.id in visited) { + return null + } + cache[event.id]?.let { return it } + val nextVisited = visited + event.id + val resolved = + when (event) { + is TextNoteEvent -> + event.toUi( + accountKey = accountKey, + profile = profiles[event.pubKey] ?: profileOf(event.pubKey, null, accountKey), + eventsById = eventsById, + profiles = profiles, + interactionStats = interactionStats, + visited = nextVisited, + resolveEvent = ::resolve, + ) + is RepostEvent -> + event.toUiRepost( + accountKey = accountKey, + profiles = profiles, + eventsById = eventsById, + visited = nextVisited, + resolveEvent = ::resolve, + ) + is GenericRepostEvent -> + event.toUiGenericRepost( + accountKey = accountKey, + profiles = profiles, + eventsById = eventsById, + visited = nextVisited, + resolveEvent = ::resolve, + ) + else -> null + } + if (resolved != null) { + cache[event.id] = resolved + } + return resolved + } + + return mapNotNull { event -> + event.toUiNotification( + accountKey = accountKey, + accountPubkey = accountPubkey, + profiles = profiles, + eventsById = eventsById, + interactionStats = interactionStats, + resolveEvent = ::resolve, + ) + } + } + private fun TextNoteEvent.toUi( accountKey: MicroBlogKey, profile: UiProfile, @@ -1291,6 +1656,31 @@ internal object NostrService : KoinComponent { return (rootIds + replyIds + positionalIds).distinct() } + private fun TextNoteEvent.immediateParentEventId(): String? { + val replyId = tags.mapNotNull(MarkedETag::parseReply).lastOrNull()?.eventId + if (replyId != null) { + return replyId + } + val rootIds = tags.mapNotNull(MarkedETag::parseRootId) + if (rootIds.isNotEmpty()) { + return rootIds.lastOrNull() + } + return tags.mapNotNull(MarkedETag::parseOnlyPositionalThreadTagsIds).lastOrNull() + } + + private fun TextNoteEvent.isDirectReplyTo(statusId: String): Boolean { + val replyIds = tags.mapNotNull(MarkedETag::parseReply).map { it.eventId } + if (replyIds.isNotEmpty()) { + return replyIds.lastOrNull() == statusId + } + val rootIds = tags.mapNotNull(MarkedETag::parseRootId) + if (rootIds.isNotEmpty()) { + return rootIds.lastOrNull() == statusId + } + val positionalIds = tags.mapNotNull(MarkedETag::parseOnlyPositionalThreadTagsIds) + return positionalIds.lastOrNull() == statusId + } + private fun TextNoteEvent.quoteEventIds(): List = tags.mapNotNull(QEventTag::parse).map { it.eventId }.distinct() private fun TextNoteEvent.quoteAddressReferences(): List
= @@ -1489,6 +1879,130 @@ internal object NostrService : KoinComponent { .maxByOrNull { it.createdAt } ?.let { resolveEvent(it, visited) } + private fun Event.toUiNotification( + accountKey: MicroBlogKey, + accountPubkey: String, + profiles: Map, + eventsById: Map, + interactionStats: Map, + resolveEvent: (Event, Set) -> UiTimelineV2.Post?, + ): UiTimelineV2.Post? = + when (this) { + is TextNoteEvent -> { + val post = + toUi( + accountKey = accountKey, + profile = profiles[pubKey] ?: profileOf(pubKey, null, accountKey), + eventsById = eventsById, + profiles = profiles, + interactionStats = interactionStats, + visited = setOf(id), + resolveEvent = { event, visited -> resolveEvent(event, visited) }, + ) + val hasParent = parentEventIds().isNotEmpty() + post.copy( + message = + notificationMessage( + accountKey = accountKey, + actor = post.user ?: profiles[pubKey] ?: profileOf(pubKey, null, accountKey), + statusKey = MicroBlogKey(id, NOSTR_HOST), + createdAt = createdAt, + icon = if (hasParent) UiIcon.Reply else UiIcon.Mention, + type = + UiTimelineV2.Message.Type.Localized( + if (hasParent) { + UiTimelineV2.Message.Type.Localized.MessageId.Reply + } else { + UiTimelineV2.Message.Type.Localized.MessageId.Mention + }, + ), + ), + ) + } + + is ReactionEvent -> { + if (content != ReactionEvent.LIKE && content.isNotBlank()) { + return null + } + if (accountPubkey !in taggedUsers()) { + return null + } + val targetId = originalPost().lastOrNull() ?: return null + val target = + eventsById[targetId]?.let { resolveEvent(it, setOf(id)) } + ?: return null + target.copy( + message = + notificationMessage( + accountKey = accountKey, + actor = profiles[pubKey] ?: profileOf(pubKey, null, accountKey), + statusKey = MicroBlogKey(id, NOSTR_HOST), + createdAt = createdAt, + icon = UiIcon.Favourite, + type = + UiTimelineV2.Message.Type.Localized( + UiTimelineV2.Message.Type.Localized.MessageId.Favourite, + ), + ), + extraKey = id, + ) + } + + is RepostEvent -> { + if (accountPubkey !in taggedUsers()) { + return null + } + val boosted = + resolvedBoostedEvent(eventsById)?.let { resolveEvent(it, setOf(id)) } + ?: return null + boosted.copy( + message = + notificationMessage( + accountKey = accountKey, + actor = profiles[pubKey] ?: profileOf(pubKey, null, accountKey), + statusKey = MicroBlogKey(id, NOSTR_HOST), + createdAt = createdAt, + icon = UiIcon.Retweet, + type = + UiTimelineV2.Message.Type.Localized( + UiTimelineV2.Message.Type.Localized.MessageId.Repost, + ), + ), + statusKey = MicroBlogKey(id, NOSTR_HOST), + internalRepost = boosted, + extraKey = id, + ) + } + + is GenericRepostEvent -> { + if (accountPubkey !in taggedUsers()) { + return null + } + val boosted = + resolvedBoostedEvent(eventsById)?.let { resolveEvent(it, setOf(id)) } + ?: return null + boosted.copy( + message = + notificationMessage( + accountKey = accountKey, + actor = profiles[pubKey] ?: profileOf(pubKey, null, accountKey), + statusKey = MicroBlogKey(id, NOSTR_HOST), + createdAt = createdAt, + icon = UiIcon.Retweet, + type = + UiTimelineV2.Message.Type.Localized( + UiTimelineV2.Message.Type.Localized.MessageId.Repost, + ), + ), + statusKey = MicroBlogKey(id, NOSTR_HOST), + internalRepost = boosted, + extraKey = id, + ) + } + + else -> null + } + private suspend fun loadEventGraph( relays: List, roots: List, @@ -1588,6 +2102,7 @@ internal object NostrService : KoinComponent { private fun referencedEventIds(event: Event): List = when (event) { is TextNoteEvent -> event.parentEventIds() + event.quoteEventIds() + is ReactionEvent -> event.originalPost() is RepostEvent -> listOfNotNull(event.boostedEventId()) is GenericRepostEvent -> listOfNotNull(event.boostedEventId()) else -> emptyList() @@ -1610,6 +2125,12 @@ internal object NostrService : KoinComponent { private fun Event.addressValue(): String = Address.assemble(kind = kind, pubKeyHex = pubKey, dTag = dTag()) + private fun Event.taggedUsers(): List = + tags + .filter { it.size > 1 && it[0] == "p" } + .map { it[1] } + .distinct() + private fun isTimelineRootEvent(event: Event): Boolean = event is TextNoteEvent || event is RepostEvent || @@ -1719,6 +2240,15 @@ internal object NostrService : KoinComponent { return fields?.let(UiProfile.BottomContent::Fields) } + private fun UserMetadata.matchesSearchQuery(query: String): Boolean = + listOfNotNull( + bestName()?.takeIf { it.isNotBlank() }, + name?.takeIf { it.isNotBlank() }, + displayName?.takeIf { it.isNotBlank() }, + nip05?.takeIf { it.isNotBlank() }, + about?.takeIf { it.isNotBlank() }, + ).any { it.contains(query, ignoreCase = true) } + private fun externalLink( display: String, target: String, @@ -1768,6 +2298,7 @@ internal object NostrService : KoinComponent { private val HEX_KEY_REGEX = Regex("^[0-9a-fA-F]{64}\$") private val HEX_DIGITS = "0123456789abcdef".toCharArray() private val timelineEventKinds = listOf(TextNoteEvent.KIND, RepostEvent.KIND, GenericRepostEvent.KIND) + private val notificationInteractionKinds = listOf(ReactionEvent.KIND, RepostEvent.KIND, GenericRepostEvent.KIND) private const val RELAY_PUBLISH_TIMEOUT_MILLIS = 1_500L private const val RELAY_TIMEOUT_MILLIS = 3_500L private const val RELAY_SETTLE_TIMEOUT_MILLIS = 350L @@ -1776,4 +2307,64 @@ internal object NostrService : KoinComponent { private const val MAX_ADDRESS_FILTER_BATCH = 32 private const val MAX_HOME_AUTHORS = 250 private const val MIN_METADATA_EVENT_LIMIT = 50 + + private fun notificationFilters( + accountPubkey: String, + pageSize: Int, + until: Long?, + type: dev.dimension.flare.data.datasource.microblog.NotificationFilter, + ): List = + when (type) { + dev.dimension.flare.data.datasource.microblog.NotificationFilter.All -> + listOf( + Filter( + kinds = listOf(TextNoteEvent.KIND), + tags = mapOf("p" to listOf(accountPubkey)), + until = until, + limit = pageSize, + ), + Filter( + kinds = notificationInteractionKinds, + tags = mapOf("p" to listOf(accountPubkey)), + until = until, + limit = pageSize, + ), + ) + + dev.dimension.flare.data.datasource.microblog.NotificationFilter.Mention -> + listOf( + Filter( + kinds = listOf(TextNoteEvent.KIND), + tags = mapOf("p" to listOf(accountPubkey)), + until = until, + limit = pageSize, + ), + ) + + else -> emptyList() + } + + private fun notificationMessage( + accountKey: MicroBlogKey, + actor: UiProfile, + statusKey: MicroBlogKey, + createdAt: Long, + icon: UiIcon, + type: UiTimelineV2.Message.Type, + ): UiTimelineV2.Message = + UiTimelineV2.Message( + user = actor, + statusKey = statusKey, + icon = icon, + type = type, + createdAt = Instant.fromEpochSeconds(createdAt).toUi(), + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.Profile.User( + accountType = AccountType.Specific(accountKey), + userKey = actor.key, + ), + ), + accountType = AccountType.Specific(accountKey), + ) } From 9938fbf5b5c36559a93a8692fca180f34f91594f Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 24 Mar 2026 16:24:30 +0900 Subject: [PATCH 6/6] add relation to nostr --- .../data/datasource/nostr/NostrLoader.kt | 50 +++- .../flare/data/network/nostr/NostrService.kt | 263 +++++++++++++++++- 2 files changed, 291 insertions(+), 22 deletions(-) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrLoader.kt index 8acb10a80..220dffd86 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrLoader.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrLoader.kt @@ -18,7 +18,12 @@ internal class NostrLoader( ) : UserLoader, RelationLoader, PostLoader { - override val supportedTypes: Set = emptySet() + override val supportedTypes: Set = + setOf( + RelationActionType.Follow, + RelationActionType.Block, + RelationActionType.Mute, + ) override suspend fun userByHandleAndHost(uiHandle: UiHandle): UiProfile = NostrService.loadProfile( @@ -54,16 +59,45 @@ internal class NostrLoader( ) } - override suspend fun follow(userKey: MicroBlogKey): Unit = throw UnsupportedOperationException("Nostr follow is not implemented yet") + override suspend fun follow(userKey: MicroBlogKey) { + NostrService.follow( + credential = credentialProvider(), + targetPubkey = userKey.id, + ) + } - override suspend fun unfollow(userKey: MicroBlogKey): Unit = - throw UnsupportedOperationException("Nostr unfollow is not implemented yet") + override suspend fun unfollow(userKey: MicroBlogKey) { + NostrService.unfollow( + credential = credentialProvider(), + targetPubkey = userKey.id, + ) + } - override suspend fun block(userKey: MicroBlogKey): Unit = throw UnsupportedOperationException("Nostr block is not implemented yet") + override suspend fun block(userKey: MicroBlogKey) { + NostrService.block( + credential = credentialProvider(), + targetPubkey = userKey.id, + ) + } - override suspend fun unblock(userKey: MicroBlogKey): Unit = throw UnsupportedOperationException("Nostr unblock is not implemented yet") + override suspend fun unblock(userKey: MicroBlogKey) { + NostrService.unblock( + credential = credentialProvider(), + targetPubkey = userKey.id, + ) + } - override suspend fun mute(userKey: MicroBlogKey): Unit = throw UnsupportedOperationException("Nostr mute is not implemented yet") + override suspend fun mute(userKey: MicroBlogKey) { + NostrService.mute( + credential = credentialProvider(), + targetPubkey = userKey.id, + ) + } - override suspend fun unmute(userKey: MicroBlogKey): Unit = throw UnsupportedOperationException("Nostr unmute is not implemented yet") + override suspend fun unmute(userKey: MicroBlogKey) { + NostrService.unmute( + credential = credentialProvider(), + targetPubkey = userKey.id, + ) + } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt index b426956a2..347bf4bfa 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt @@ -3,16 +3,20 @@ package dev.dimension.flare.data.network.nostr import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.pTag import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip02FollowList.ReadWrite +import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag @@ -29,8 +33,15 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip19Bech32.toNsec import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUserIdSet +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.userIdSet import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportType +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import dev.dimension.flare.common.JSON @@ -541,12 +552,150 @@ internal object NostrService : KoinComponent { targetPubkey: String, ): dev.dimension.flare.ui.model.UiRelation { val relays = credential.relays.ifEmpty { defaultRelays } - val follows = loadAuthors(relays, credential.pubkey) + val follows = loadLatestContactList(relays, credential.pubkey)?.verifiedFollowKeySet().orEmpty() + val blocks = loadLatestBlockList(relays, credential.pubkey)?.tags?.userIdSet().orEmpty() + val mutes = loadLatestMuteList(relays, credential.pubkey)?.tags?.mutedUserIdSet().orEmpty() return dev.dimension.flare.ui.model.UiRelation( following = targetPubkey in follows, + blocking = targetPubkey in blocks, + muted = targetPubkey in mutes, ) } + internal suspend fun follow( + credential: UiAccount.Nostr.Credential, + targetPubkey: String, + ) { + val relays = credential.relays.ifEmpty { defaultRelays } + val signer = createSigner(requireNotNull(exportAccount(credential).nsec)) + val latest = loadLatestContactList(relays, credential.pubkey) + val event = + if (latest != null) { + ContactListEvent.followUser( + earlierVersion = latest, + pubKeyHex = targetPubkey, + signer = signer, + ) + } else { + ContactListEvent.createFromScratch( + followUsers = listOf(ContactTag(targetPubkey)), + relayUse = defaultRelaySet(relays), + signer = signer, + ) + } + publishEvent(credential, event) + } + + internal suspend fun unfollow( + credential: UiAccount.Nostr.Credential, + targetPubkey: String, + ) { + val relays = credential.relays.ifEmpty { defaultRelays } + val signer = createSigner(requireNotNull(exportAccount(credential).nsec)) + val latest = loadLatestContactList(relays, credential.pubkey) + val event = + if (latest != null) { + ContactListEvent.unfollowUser( + earlierVersion = latest, + pubKeyHex = targetPubkey, + signer = signer, + ) + } else { + ContactListEvent.createFromScratch( + followUsers = emptyList(), + relayUse = defaultRelaySet(relays), + signer = signer, + ) + } + publishEvent(credential, event) + } + + internal suspend fun block( + credential: UiAccount.Nostr.Credential, + targetPubkey: String, + ) { + val relays = credential.relays.ifEmpty { defaultRelays } + val signer = createSigner(requireNotNull(exportAccount(credential).nsec)) + val latest = loadLatestBlockList(relays, credential.pubkey) + val event = + if (latest != null) { + PeopleListEvent.addUser( + earlierVersion = latest, + pubKeyHex = targetPubkey, + relayHint = null, + isPrivate = false, + signer = signer, + ) + } else { + PeopleListEvent.create( + name = "Block list", + publicMembers = listOf(UserTag(targetPubkey)), + privateMembers = emptyList(), + signer = signer, + dTag = PeopleListEvent.BLOCK_LIST_D_TAG, + ) + } + publishEvent(credential, event) + } + + internal suspend fun unblock( + credential: UiAccount.Nostr.Credential, + targetPubkey: String, + ) { + val relays = credential.relays.ifEmpty { defaultRelays } + val signer = createSigner(requireNotNull(exportAccount(credential).nsec)) + val latest = loadLatestBlockList(relays, credential.pubkey) ?: return + val event = + PeopleListEvent.removeUser( + earlierVersion = latest, + pubKeyHex = targetPubkey, + isUserPrivate = false, + signer = signer, + ) + publishEvent(credential, event) + } + + internal suspend fun mute( + credential: UiAccount.Nostr.Credential, + targetPubkey: String, + ) { + val relays = credential.relays.ifEmpty { defaultRelays } + val signer = createSigner(requireNotNull(exportAccount(credential).nsec)) + val latest = loadLatestMuteList(relays, credential.pubkey) + val event = + if (latest != null) { + MuteListEvent.add( + earlierVersion = latest, + mute = UserTag(targetPubkey), + isPrivate = false, + signer = signer, + ) + } else { + MuteListEvent.create( + mute = UserTag(targetPubkey), + isPrivate = false, + signer = signer, + ) + } + publishEvent(credential, event) + } + + internal suspend fun unmute( + credential: UiAccount.Nostr.Credential, + targetPubkey: String, + ) { + val relays = credential.relays.ifEmpty { defaultRelays } + val signer = createSigner(requireNotNull(exportAccount(credential).nsec)) + val latest = loadLatestMuteList(relays, credential.pubkey) ?: return + val event = + MuteListEvent.remove( + earlierVersion = latest, + mute = UserTag(targetPubkey), + signer = signer, + ) + publishEvent(credential, event) + } + internal fun createTextNoteEvent( secretKey: String, content: String, @@ -718,25 +867,68 @@ internal object NostrService : KoinComponent { relays: List, accountPubkey: String, ): List { - val latestContacts = - queryAllRelays( - relays = relays, - filters = - listOf( - Filter( - authors = listOf(accountPubkey), - kinds = listOf(ContactListEvent.KIND), - limit = 1, - ), - ), - ).filterIsInstance() - .maxByOrNull { it.createdAt } + val latestContacts = loadLatestContactList(relays, accountPubkey) return (latestContacts?.verifiedFollowKeySet().orEmpty() + accountPubkey) .distinct() .take(MAX_HOME_AUTHORS) } + private suspend fun loadLatestContactList( + relays: List, + accountPubkey: String, + ): ContactListEvent? = + queryAllRelays( + relays = relays, + filters = + listOf( + Filter( + authors = listOf(accountPubkey), + kinds = listOf(ContactListEvent.KIND), + limit = 1, + ), + ), + ).filterIsInstance() + .maxByOrNull { it.createdAt } + + private suspend fun loadLatestMuteList( + relays: List, + accountPubkey: String, + ): MuteListEvent? = + queryAllRelays( + relays = relays, + filters = + listOf( + Filter( + authors = listOf(accountPubkey), + kinds = listOf(MuteListEvent.KIND), + limit = 1, + ), + ), + ).filterIsInstance() + .maxByOrNull { it.createdAt } + + private suspend fun loadLatestBlockList( + relays: List, + accountPubkey: String, + ): PeopleListEvent? = + queryAllRelays( + relays = relays, + filters = + listOf( + Filter( + authors = listOf(accountPubkey), + kinds = listOf(PeopleListEvent.KIND), + tags = mapOf("d" to listOf(PeopleListEvent.BLOCK_LIST_D_TAG)), + limit = 10, + ), + ), + ).filterIsInstance() + .maxByOrNull { it.createdAt } + + private fun defaultRelaySet(relays: List): Map = + relays.associateWith { ReadWrite(read = true, write = true) } + private suspend fun loadMetadata( relays: List, authors: List, @@ -1142,6 +1334,49 @@ internal object NostrService : KoinComponent { ) } + private fun createSigner(secretKey: String): NostrSigner { + val syncSigner = + com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync( + keyPair = KeyPair(privKey = normalizeSecret(secretKey).hex.hexToByteArray()), + ) + return object : NostrSigner(syncSigner.pubKey) { + override fun isWriteable(): Boolean = true + + override suspend fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T = syncSigner.sign(createdAt, kind, tags, content) + + override suspend fun nip04Encrypt( + plaintext: String, + toPublicKey: String, + ): String = syncSigner.nip04Encrypt(plaintext, toPublicKey) + + override suspend fun nip04Decrypt( + ciphertext: String, + fromPublicKey: String, + ): String = syncSigner.nip04Decrypt(ciphertext, fromPublicKey) + + override suspend fun nip44Encrypt( + plaintext: String, + toPublicKey: String, + ): String = syncSigner.nip44Encrypt(plaintext, toPublicKey) + + override suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: String, + ): String = syncSigner.nip44Decrypt(ciphertext, fromPublicKey) + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = syncSigner.decryptZapEvent(event) + + override suspend fun deriveKey(nonce: String): String = syncSigner.deriveKey(nonce) + + override fun hasForegroundSupport(): Boolean = false + } + } + private fun EventTemplate.sign( pubKey: String, privKey: ByteArray,