From 644010d785f216cd8872cf45dc7567d0db7eb028 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 7 Jul 2026 12:41:51 +0900 Subject: [PATCH 1/3] initial tumblr support --- .gitignore | 1 + app/build.gradle.kts | 2 +- .../flare/di/AndroidKoinApplication.kt | 2 + apple-shared/build.gradle.kts | 2 + .../flare/apple/shared/PlatformSpecs.ios.kt | 2 + .../flare/apple/shared/PlatformSpecs.macos.kt | 2 + .../FlareAppleCore/PresentationMappings.swift | 2 + .../TimelineItem/StatusView.swift | 4 + appleApp/ios/UI/Screen/ComposeScreen.swift | 2 + .../UI/Screen/ServiceSelectionScreen.swift | 2 + .../macos/UI/Screen/MacComposeScreen.swift | 2 + .../UI/Screen/ServiceSelectionScreen.swift | 2 + .../dimension/flare/ui/component/UiIconExt.kt | 2 + .../flare/ui/model/PlatformTypeIcon.kt | 2 + desktopApp/build.gradle.kts | 1 + .../flare/di/DesktopKoinApplication.kt | 2 + settings.gradle.kts | 1 + .../flare/common/BuildConfig.android.kt | 2 +- .../data/datasource/microblog/PostEvent.kt | 39 + .../dev/dimension/flare/model/PlatformType.kt | 1 + .../dev/dimension/flare/ui/model/UiIcon.kt | 2 + .../flare/ui/model/mapper/TumblrActionMenu.kt | 77 ++ .../PlatformOAuthPendingRepositoryTest.kt | 30 +- social/tumblr/build.gradle.kts | 130 +++ .../datasource/tumblr/TumblrDataSource.kt | 373 ++++++ .../data/datasource/tumblr/TumblrKeys.kt | 49 + .../data/datasource/tumblr/TumblrLoader.kt | 70 ++ .../data/datasource/tumblr/TumblrLoaders.kt | 253 ++++ .../data/datasource/tumblr/TumblrMapper.kt | 1027 +++++++++++++++++ .../flare/data/network/tumblr/TumblrModels.kt | 499 ++++++++ .../network/tumblr/TumblrPlatformDetector.kt | 23 + .../data/network/tumblr/TumblrResources.kt | 162 +++ .../data/network/tumblr/TumblrService.kt | 309 +++++ .../flare/data/platform/TumblrPlatformSpec.kt | 166 +++ .../ui/presenter/login/TumblrLoginProvider.kt | 279 +++++ .../datasource/tumblr/TumblrDataSourceTest.kt | 49 + .../data/datasource/tumblr/TumblrKeysTest.kt | 39 + .../datasource/tumblr/TumblrMapperTest.kt | 724 ++++++++++++ .../data/network/tumblr/TumblrServiceTest.kt | 357 ++++++ 39 files changed, 4681 insertions(+), 12 deletions(-) create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/TumblrActionMenu.kt create mode 100644 social/tumblr/build.gradle.kts create mode 100644 social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrDataSource.kt create mode 100644 social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrKeys.kt create mode 100644 social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrLoader.kt create mode 100644 social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrLoaders.kt create mode 100644 social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrMapper.kt create mode 100644 social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrModels.kt create mode 100644 social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrPlatformDetector.kt create mode 100644 social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrResources.kt create mode 100644 social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrService.kt create mode 100644 social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/platform/TumblrPlatformSpec.kt create mode 100644 social/tumblr/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/TumblrLoginProvider.kt create mode 100644 social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrDataSourceTest.kt create mode 100644 social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrKeysTest.kt create mode 100644 social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrMapperTest.kt create mode 100644 social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/network/tumblr/TumblrServiceTest.kt diff --git a/.gitignore b/.gitignore index 8090a4fb91..1c495698b4 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ local.properties /.idea *.jks signing.properties +secret.properties build/ */Podfile.lock diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 402637852b..b01a975c80 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -133,6 +133,7 @@ dependencies { implementation(projects.social.misskey) implementation(projects.social.nostr) implementation(projects.social.pixiv) + implementation(projects.social.tumblr) implementation(projects.social.vvo) implementation(projects.social.xqt) implementation(projects.feature.login) @@ -245,4 +246,3 @@ kotlin { } } } - diff --git a/app/src/main/java/dev/dimension/flare/di/AndroidKoinApplication.kt b/app/src/main/java/dev/dimension/flare/di/AndroidKoinApplication.kt index a3c95d9e27..6baf96c2da 100644 --- a/app/src/main/java/dev/dimension/flare/di/AndroidKoinApplication.kt +++ b/app/src/main/java/dev/dimension/flare/di/AndroidKoinApplication.kt @@ -8,6 +8,7 @@ import dev.dimension.flare.data.platform.MisskeyPlatformSpec import dev.dimension.flare.data.platform.NostrPlatformSpec import dev.dimension.flare.data.platform.PixivPlatformSpec import dev.dimension.flare.data.platform.RssTimelineSpecs +import dev.dimension.flare.data.platform.TumblrPlatformSpec import dev.dimension.flare.data.platform.VvoPlatformSpec import dev.dimension.flare.data.platform.XqtPlatformSpec import dev.dimension.flare.model.PlatformRuntimeData @@ -36,6 +37,7 @@ internal fun runtimeData(allRssTimelineLoaderFactory: AllRssTimelineLoaderFactor BlueskyPlatformSpec, FanboxPlatformSpec, PixivPlatformSpec, + TumblrPlatformSpec, XqtPlatformSpec, VvoPlatformSpec, ), diff --git a/apple-shared/build.gradle.kts b/apple-shared/build.gradle.kts index 118b8e2541..d2b362c864 100644 --- a/apple-shared/build.gradle.kts +++ b/apple-shared/build.gradle.kts @@ -28,6 +28,7 @@ kotlin { projects.social.mastodon, projects.social.misskey, projects.social.pixiv, + projects.social.tumblr, projects.social.vvo, projects.social.xqt, projects.feature.loginApi, @@ -67,6 +68,7 @@ kotlin { api(projects.social.mastodon) api(projects.social.misskey) api(projects.social.pixiv) + api(projects.social.tumblr) api(projects.social.vvo) api(projects.social.xqt) api(projects.feature.loginApi) diff --git a/apple-shared/src/iosMain/kotlin/dev/dimension/flare/apple/shared/PlatformSpecs.ios.kt b/apple-shared/src/iosMain/kotlin/dev/dimension/flare/apple/shared/PlatformSpecs.ios.kt index b3b3bf30a9..c45066d18b 100644 --- a/apple-shared/src/iosMain/kotlin/dev/dimension/flare/apple/shared/PlatformSpecs.ios.kt +++ b/apple-shared/src/iosMain/kotlin/dev/dimension/flare/apple/shared/PlatformSpecs.ios.kt @@ -6,6 +6,7 @@ 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.PixivPlatformSpec +import dev.dimension.flare.data.platform.TumblrPlatformSpec import dev.dimension.flare.data.platform.VvoPlatformSpec import dev.dimension.flare.data.platform.XqtPlatformSpec import dev.dimension.flare.model.PlatformSpec @@ -18,6 +19,7 @@ internal actual fun platformSpecs(): List = BlueskyPlatformSpec, FanboxPlatformSpec, PixivPlatformSpec, + TumblrPlatformSpec, XqtPlatformSpec, VvoPlatformSpec, ) diff --git a/apple-shared/src/macosMain/kotlin/dev/dimension/flare/apple/shared/PlatformSpecs.macos.kt b/apple-shared/src/macosMain/kotlin/dev/dimension/flare/apple/shared/PlatformSpecs.macos.kt index f5e49734a2..1d7813aeaf 100644 --- a/apple-shared/src/macosMain/kotlin/dev/dimension/flare/apple/shared/PlatformSpecs.macos.kt +++ b/apple-shared/src/macosMain/kotlin/dev/dimension/flare/apple/shared/PlatformSpecs.macos.kt @@ -5,6 +5,7 @@ import dev.dimension.flare.data.platform.FanboxPlatformSpec import dev.dimension.flare.data.platform.MastodonPlatformSpec import dev.dimension.flare.data.platform.MisskeyPlatformSpec import dev.dimension.flare.data.platform.PixivPlatformSpec +import dev.dimension.flare.data.platform.TumblrPlatformSpec import dev.dimension.flare.data.platform.VvoPlatformSpec import dev.dimension.flare.data.platform.XqtPlatformSpec import dev.dimension.flare.model.PlatformSpec @@ -16,6 +17,7 @@ internal actual fun platformSpecs(): List = BlueskyPlatformSpec, FanboxPlatformSpec, PixivPlatformSpec, + TumblrPlatformSpec, XqtPlatformSpec, VvoPlatformSpec, ) diff --git a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleCore/PresentationMappings.swift b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleCore/PresentationMappings.swift index 8c63625a86..0abbb475c3 100644 --- a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleCore/PresentationMappings.swift +++ b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleCore/PresentationMappings.swift @@ -281,6 +281,8 @@ public extension UiIcon { .pixiv case .fanbox: .pixiv + case .tumblr: + .squareRss case .list: .list case .feeds, .rss: diff --git a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/TimelineItem/StatusView.swift b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/TimelineItem/StatusView.swift index b6b3bc7075..586c423b93 100644 --- a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/TimelineItem/StatusView.swift +++ b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/TimelineItem/StatusView.swift @@ -438,6 +438,10 @@ public struct StatusView: View { Image(fontAwesome: .pixiv) .font(.caption) .foregroundStyle(.secondary) + case .tumblr: + Image(fontAwesome: .squareRss) + .font(.caption) + .foregroundStyle(.secondary) } } if !isDetail { diff --git a/appleApp/ios/UI/Screen/ComposeScreen.swift b/appleApp/ios/UI/Screen/ComposeScreen.swift index 8840e27657..ba6d8302d7 100644 --- a/appleApp/ios/UI/Screen/ComposeScreen.swift +++ b/appleApp/ios/UI/Screen/ComposeScreen.swift @@ -696,6 +696,8 @@ private extension UiProfile { return .pixiv case .fanbox: return .fanbox + case .tumblr: + return .tumblr case .xQt: return .x case .vvo: diff --git a/appleApp/ios/UI/Screen/ServiceSelectionScreen.swift b/appleApp/ios/UI/Screen/ServiceSelectionScreen.swift index 4737cb84f6..8cdc119906 100644 --- a/appleApp/ios/UI/Screen/ServiceSelectionScreen.swift +++ b/appleApp/ios/UI/Screen/ServiceSelectionScreen.swift @@ -283,6 +283,8 @@ struct ServiceSelectionScreen: View { return "Pixiv" case .fanbox: return "FANBOX" + case .tumblr: + return "Tumblr" } } } diff --git a/appleApp/macos/UI/Screen/MacComposeScreen.swift b/appleApp/macos/UI/Screen/MacComposeScreen.swift index 08fbb0ac5d..1128a92988 100644 --- a/appleApp/macos/UI/Screen/MacComposeScreen.swift +++ b/appleApp/macos/UI/Screen/MacComposeScreen.swift @@ -1245,6 +1245,8 @@ private extension UiProfile { return .pixiv case .fanbox: return .fanbox + case .tumblr: + return .tumblr case .xQt: return .x case .vvo: diff --git a/appleApp/macos/UI/Screen/ServiceSelectionScreen.swift b/appleApp/macos/UI/Screen/ServiceSelectionScreen.swift index 7d2cacebe8..2dd0160521 100644 --- a/appleApp/macos/UI/Screen/ServiceSelectionScreen.swift +++ b/appleApp/macos/UI/Screen/ServiceSelectionScreen.swift @@ -293,6 +293,8 @@ struct ServiceSelectionScreen: View { return "Pixiv" case .fanbox: return "Fanbox" + case .tumblr: + return "Tumblr" } } diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/UiIconExt.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/UiIconExt.kt index 4e291241ab..bd86ebf9fe 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/UiIconExt.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/UiIconExt.kt @@ -8,6 +8,7 @@ import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.brands.Bluesky import compose.icons.fontawesomeicons.brands.Mastodon import compose.icons.fontawesomeicons.brands.Pixiv +import compose.icons.fontawesomeicons.brands.Tumblr import compose.icons.fontawesomeicons.brands.Twitter import compose.icons.fontawesomeicons.brands.Weibo import compose.icons.fontawesomeicons.brands.XTwitter @@ -103,6 +104,7 @@ public fun UiIcon.toImageVector(): ImageVector = UiIcon.Bluesky -> FontAwesomeIcons.Brands.Bluesky UiIcon.Pixiv -> FontAwesomeIcons.Brands.Pixiv UiIcon.Fanbox -> FontAwesomeIcons.Brands.Pixiv + UiIcon.Tumblr -> FontAwesomeIcons.Brands.Tumblr UiIcon.Nostr -> FontAwesomeIcons.Brands.Nostr UiIcon.Twitter -> FontAwesomeIcons.Brands.Twitter UiIcon.X -> FontAwesomeIcons.Brands.XTwitter 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 index f50bfd7bf6..8a1fe376c9 100644 --- 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 @@ -8,6 +8,7 @@ import compose.icons.fontawesomeicons.brands.Bluesky import compose.icons.fontawesomeicons.brands.Mastodon import compose.icons.fontawesomeicons.brands.Pix import compose.icons.fontawesomeicons.brands.Pixiv +import compose.icons.fontawesomeicons.brands.Tumblr import compose.icons.fontawesomeicons.brands.Weibo import compose.icons.fontawesomeicons.brands.XTwitter import compose.icons.fontawesomeicons.solid.Image @@ -24,6 +25,7 @@ public val PlatformType.brandIcon: ImageVector PlatformType.Bluesky -> FontAwesomeIcons.Brands.Bluesky PlatformType.Pixiv -> FontAwesomeIcons.Brands.Pixiv PlatformType.Fanbox -> FontAwesomeIcons.Brands.Pixiv + PlatformType.Tumblr -> FontAwesomeIcons.Brands.Tumblr PlatformType.xQt -> FontAwesomeIcons.Brands.XTwitter PlatformType.VVo -> FontAwesomeIcons.Brands.Weibo } diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 040c3dc663..adf298f5ba 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -22,6 +22,7 @@ dependencies { implementation(projects.social.misskey) implementation(projects.social.nostr) implementation(projects.social.pixiv) + implementation(projects.social.tumblr) implementation(projects.social.vvo) implementation(projects.social.xqt) implementation(projects.feature.login) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopKoinApplication.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopKoinApplication.kt index af0ed469a5..fe898cce31 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopKoinApplication.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopKoinApplication.kt @@ -8,6 +8,7 @@ import dev.dimension.flare.data.platform.MisskeyPlatformSpec import dev.dimension.flare.data.platform.NostrPlatformSpec import dev.dimension.flare.data.platform.PixivPlatformSpec import dev.dimension.flare.data.platform.RssTimelineSpecs +import dev.dimension.flare.data.platform.TumblrPlatformSpec import dev.dimension.flare.data.platform.VvoPlatformSpec import dev.dimension.flare.data.platform.XqtPlatformSpec import dev.dimension.flare.model.PlatformRuntimeData @@ -36,6 +37,7 @@ internal fun runtimeData(allRssTimelineLoaderFactory: AllRssTimelineLoaderFactor BlueskyPlatformSpec, FanboxPlatformSpec, PixivPlatformSpec, + TumblrPlatformSpec, XqtPlatformSpec, VvoPlatformSpec, ), diff --git a/settings.gradle.kts b/settings.gradle.kts index f57c3b201c..259bc37b61 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,6 +30,7 @@ include(":social:mastodon") include(":social:misskey") include(":social:nostr") include(":social:pixiv") +include(":social:tumblr") include(":social:vvo") include(":social:xqt") include(":feature:login-api") diff --git a/shared/src/androidMain/kotlin/dev/dimension/flare/common/BuildConfig.android.kt b/shared/src/androidMain/kotlin/dev/dimension/flare/common/BuildConfig.android.kt index 98767fbbe9..a5d055b7c7 100644 --- a/shared/src/androidMain/kotlin/dev/dimension/flare/common/BuildConfig.android.kt +++ b/shared/src/androidMain/kotlin/dev/dimension/flare/common/BuildConfig.android.kt @@ -3,5 +3,5 @@ package dev.dimension.flare.common internal actual object BuildConfig { actual val debug: Boolean - get() = false + get() = true } \ No newline at end of file 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 9d7c84eee2..08e0681a0e 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 @@ -15,6 +15,8 @@ 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.pixivBookmark +import dev.dimension.flare.ui.model.mapper.tumblrLike +import dev.dimension.flare.ui.model.mapper.tumblrRepost import dev.dimension.flare.ui.model.mapper.vvoFavorite import dev.dimension.flare.ui.model.mapper.vvoLike import dev.dimension.flare.ui.model.mapper.vvoLikeComment @@ -473,6 +475,43 @@ public sealed interface PostEvent { ) } } + + @Serializable + public sealed interface Tumblr : PostEvent { + @Serializable + public data class Like( + public override val postKey: MicroBlogKey, + public val liked: Boolean, + public val count: Long = 0, + public val accountKey: MicroBlogKey, + ) : Tumblr, + UpdatePostActionMenuEvent { + public override fun nextActionMenu(): ActionMenu.Item = + ActionMenu.tumblrLike( + statusKey = postKey, + liked = !liked, + count = (count + if (!liked) 1 else -1).coerceAtLeast(0), + accountKey = accountKey, + ) + } + + @Serializable + public data class Repost( + public override val postKey: MicroBlogKey, + public val reposted: Boolean, + public val count: Long = 0, + public val accountKey: MicroBlogKey, + ) : Tumblr, + UpdatePostActionMenuEvent { + public override fun nextActionMenu(): ActionMenu.Item = + ActionMenu.tumblrRepost( + statusKey = postKey, + reposted = !reposted, + count = (count + if (!reposted) 1 else -1).coerceAtLeast(0), + accountKey = accountKey, + ) + } + } } @HiddenFromObjC 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 de3ab02448..b44e4a67a1 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformType.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformType.kt @@ -20,6 +20,7 @@ public enum class PlatformType { VVo, Nostr, Fanbox, + Tumblr, } @Immutable 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 b0c2a2fb60..e6d273ac4f 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 @@ -65,6 +65,7 @@ public enum class UiIcon { UnFavourite, Pixiv, Fanbox, + Tumblr, } /** @@ -81,6 +82,7 @@ public val TabPickerUiIcons: List = UiIcon.Misskey, UiIcon.Bluesky, UiIcon.Pixiv, + UiIcon.Tumblr, UiIcon.Weibo, UiIcon.Nostr, UiIcon.X, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/TumblrActionMenu.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/TumblrActionMenu.kt new file mode 100644 index 0000000000..2f03711dd5 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/TumblrActionMenu.kt @@ -0,0 +1,77 @@ +package dev.dimension.flare.ui.model.mapper + +import dev.dimension.flare.data.datasource.microblog.ActionMenu +import dev.dimension.flare.data.datasource.microblog.PostActionFamily +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 + +public fun ActionMenu.Companion.tumblrLike( + statusKey: MicroBlogKey, + liked: Boolean, + count: Long, + accountKey: MicroBlogKey, +): ActionMenu.Item = + ActionMenu.Item( + updateKey = "tumblr_like_$statusKey", + icon = if (liked) UiIcon.Unlike else UiIcon.Like, + text = + ActionMenu.Item.Text.Localized( + if (liked) { + ActionMenu.Item.Text.Localized.Type.Unlike + } else { + ActionMenu.Item.Text.Localized.Type.Like + }, + ), + count = UiNumber(count), + color = if (liked) ActionMenu.Item.Color.Red else null, + clickEvent = + ClickEvent.event( + accountKey, + PostEvent.Tumblr.Like( + postKey = statusKey, + liked = liked, + count = count, + accountKey = accountKey, + ), + ), + actionFamily = PostActionFamily.Like, + ) + +public fun ActionMenu.Companion.tumblrRepost( + statusKey: MicroBlogKey, + reposted: Boolean, + count: Long, + accountKey: MicroBlogKey, +): ActionMenu.Item = + ActionMenu.Item( + updateKey = "tumblr_repost_$statusKey", + icon = if (reposted) UiIcon.Unretweet else UiIcon.Retweet, + text = + ActionMenu.Item.Text.Localized( + if (reposted) { + ActionMenu.Item.Text.Localized.Type.Unretweet + } else { + ActionMenu.Item.Text.Localized.Type.Retweet + }, + ), + count = UiNumber(count), + color = if (reposted) ActionMenu.Item.Color.PrimaryColor else null, + clickEvent = + if (reposted) { + ClickEvent.Noop + } else { + ClickEvent.event( + accountKey, + PostEvent.Tumblr.Repost( + postKey = statusKey, + reposted = reposted, + count = count, + accountKey = accountKey, + ), + ) + }, + actionFamily = PostActionFamily.Repost, + ) diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datastore/PlatformOAuthPendingRepositoryTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datastore/PlatformOAuthPendingRepositoryTest.kt index f3c52c3406..ccaeed119b 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datastore/PlatformOAuthPendingRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datastore/PlatformOAuthPendingRepositoryTest.kt @@ -6,26 +6,36 @@ import dev.dimension.flare.data.io.OkioFileStorage import dev.dimension.flare.deleteTestRootPath import dev.dimension.flare.model.PlatformType import kotlinx.coroutines.test.runTest +import okio.Path import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull class PlatformOAuthPendingRepositoryTest { - private val root = createTestRootPath() - private val repository = - PlatformOAuthPendingRepository( - AppDataStore( - OkioFileStorage( - fileSystem = createTestFileSystem(), - root = root, + private lateinit var root: Path + private lateinit var repository: PlatformOAuthPendingRepository + + @BeforeTest + fun setup() { + root = createTestRootPath() + repository = + PlatformOAuthPendingRepository( + AppDataStore( + OkioFileStorage( + fileSystem = createTestFileSystem(), + root = root, + ), ), - ), - ) + ) + } @AfterTest fun tearDown() { - deleteTestRootPath(root) + if (::root.isInitialized) { + deleteTestRootPath(root) + } } @Test diff --git a/social/tumblr/build.gradle.kts b/social/tumblr/build.gradle.kts new file mode 100644 index 0000000000..af53ff3f6d --- /dev/null +++ b/social/tumblr/build.gradle.kts @@ -0,0 +1,130 @@ +import dev.dimension.flare.buildlogic.FlarePlatform +import dev.dimension.flare.buildlogic.flare +import java.util.Properties + +plugins { + id("dev.dimension.flare.multiplatform-library") + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) + alias(libs.plugins.ktorfit) + id("com.github.gmazzo.buildconfig") version "6.0.10" +} + +val secretProperties = + Properties().apply { + val file = rootProject.file("secret.properties") + if (file.exists()) { + file.inputStream().use(::load) + } + } + +fun tumblrSecret( + environmentName: String, + propertyName: String, + defaultValue: String = "", +) = providers + .environmentVariable(environmentName) + .orElse( + providers.provider { + secretProperties.getProperty(propertyName) ?: defaultValue + }, + ) + +val tumblrClientId = tumblrSecret("TUMBLR_CLIENT_ID", "tumblr.clientId") +val tumblrClientSecret = tumblrSecret("TUMBLR_CLIENT_SECRET", "tumblr.clientSecret") +val tumblrRedirectUri = + tumblrSecret( + environmentName = "TUMBLR_REDIRECT_URI", + propertyName = "tumblr.redirectUri", + defaultValue = "https://flareapp.moe/tumblr-callback.html", + ) + +kotlin { + flare { + namespace = "dev.dimension.flare.social.tumblr" + platforms( + FlarePlatform.ANDROID, + FlarePlatform.JVM, + FlarePlatform.IOS, + FlarePlatform.MACOS, + ) + ksp(libs.ktorfit.ksp) + } + + sourceSets { + val commonMain by getting { + dependencies { + api(projects.shared) + api(projects.feature.loginApi) + implementation(libs.bundles.kotlinx) + implementation(dependencies.platform(libs.koin.bom)) + implementation(libs.koin.core) + implementation(libs.bundles.ktorfit) + implementation(libs.bundles.ktor) + implementation(libs.ktor.client.resources) + implementation(libs.okio) + implementation(libs.kotlin.codepoints.deluxe) + implementation(libs.paging.common) + } + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.paging.testing) + implementation(libs.ktor.client.mock) + } + } + val androidJvmMain by getting { + dependencies { + implementation(libs.ktor.client.okhttp) + } + } + val appleMain by getting { + dependencies { + implementation(libs.ktor.client.darwin) + } + } + } +} + +buildConfig { + packageName("dev.dimension.flare.social.tumblr") + className("TumblrBuildConfig") + useKotlinOutput() + buildConfigField("clientId", tumblrClientId) + buildConfigField("clientSecret", tumblrClientSecret) + buildConfigField("redirectUri", tumblrRedirectUri) + buildConfigField("configured") { + type("kotlin.Boolean") + expression( + providers.provider { + (tumblrClientId.get().isNotBlank() && tumblrClientSecret.get().isNotBlank()).toString() + }, + ) + } +} + +val checkTumblrSecrets by tasks.registering { + group = "verification" + description = "Checks Tumblr OAuth build secrets for release builds." + doLast { + val missing = + buildList { + if (tumblrClientId.get().isBlank()) add("TUMBLR_CLIENT_ID or tumblr.clientId") + if (tumblrClientSecret.get().isBlank()) add("TUMBLR_CLIENT_SECRET or tumblr.clientSecret") + if (tumblrRedirectUri.get().isBlank()) add("TUMBLR_REDIRECT_URI or tumblr.redirectUri") + } + if (missing.isNotEmpty()) { + throw GradleException("Missing Tumblr OAuth secret(s): ${missing.joinToString()}") + } + } +} + +tasks.configureEach { + if (name.contains("Release")) { + dependsOn(checkTumblrSecrets) + } +} diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrDataSource.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrDataSource.kt new file mode 100644 index 0000000000..f00a90319b --- /dev/null +++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrDataSource.kt @@ -0,0 +1,373 @@ +package dev.dimension.flare.data.datasource.tumblr + +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.ComposeDataSource +import dev.dimension.flare.data.datasource.microblog.ComposeType +import dev.dimension.flare.data.datasource.microblog.DatabaseUpdater +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.TimelineTabConfigurationDataSource +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 +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader +import dev.dimension.flare.data.datasource.microblog.paging.notSupported +import dev.dimension.flare.data.model.IconType +import dev.dimension.flare.data.model.appearance.AppearanceKeys +import dev.dimension.flare.data.model.appearance.AppearancePatch +import dev.dimension.flare.data.model.tab.ShortcutSpec +import dev.dimension.flare.data.model.tab.TimelineCandidate +import dev.dimension.flare.data.model.tab.TimelineSpec +import dev.dimension.flare.data.network.tumblr.TumblrCreatePostRequest +import dev.dimension.flare.data.network.tumblr.TumblrNpfBlock +import dev.dimension.flare.data.network.tumblr.TumblrNpfMedia +import dev.dimension.flare.data.network.tumblr.TumblrService +import dev.dimension.flare.data.network.tumblr.toTumblrComposeMediaFile +import dev.dimension.flare.data.platform.CommonTimelineSpecs +import dev.dimension.flare.data.platform.TumblrCredential +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiHashtag +import dev.dimension.flare.ui.model.UiIcon +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.UiStrings +import dev.dimension.flare.ui.model.UiText +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.asText +import dev.dimension.flare.ui.presenter.compose.ComposeStatus +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first + +private const val TUMBLR_TEXT_BLOCK_MAX_LENGTH = 4096 +private const val TUMBLR_MAX_MEDIA_COUNT = 10 + +internal class TumblrDataSource( + override val accountKey: MicroBlogKey, + private val credentialFlow: Flow, + private val updateCredential: suspend (TumblrCredential) -> Unit, +) : AuthenticatedMicroblogDataSource, + ComposeDataSource, + PostDataSource, + RelationDataSource, + TimelineTabConfigurationDataSource, + UserDataSource, + PostEventHandler.Handler { + private val service = + TumblrService( + credentialFlow = credentialFlow, + onCredentialRefreshed = updateCredential, + ) + + private val loader by lazy { + TumblrLoader( + service = service, + accountKey = accountKey, + ) + } + + override val postHandler: PostHandler by lazy { + PostHandler( + accountType = AccountType.Specific(accountKey), + loader = loader, + ) + } + + override val postEventHandler: PostEventHandler by lazy { + PostEventHandler( + accountType = AccountType.Specific(accountKey), + handler = this, + ) + } + + override val relationHandler: RelationHandler by lazy { + RelationHandler( + accountType = AccountType.Specific(accountKey), + dataSource = loader, + ) + } + + override val userHandler: UserHandler by lazy { + UserHandler( + host = accountKey.host, + loader = loader, + ) + } + + override val supportedRelationTypes: Set = loader.supportedTypes + + override val defaultTabs: ImmutableList> by lazy { + persistentListOf( + tumblrHomeCandidate( + title = UiText.Raw("Tumblr"), + ).withFullWidthPost(), + ) + } + + override val builtInTimelineTabs: ImmutableList> by lazy { + persistentListOf( + tumblrHomeCandidate().withFullWidthPost(), + ) + } + + override val shortcuts: ImmutableList by lazy { + persistentListOf( + ShortcutSpec( + title = UiStrings.Home, + icon = UiIcon.Home, + target = + ShortcutSpec.Target.Timeline( + tumblrHomeCandidate().withFullWidthPost(), + ), + ), + ) + } + + override fun homeTimeline(): RemoteLoader = + TumblrHomeTimelineLoader( + service = service, + accountKey = accountKey, + ) + + override fun userTimeline( + userKey: MicroBlogKey, + mediaOnly: Boolean, + ): RemoteLoader = + TumblrBlogTimelineLoader( + service = service, + accountKey = accountKey, + blogKey = userKey, + mediaOnly = mediaOnly, + ) + + override fun context(statusKey: MicroBlogKey): RemoteLoader = + TumblrStatusDetailLoader( + service = service, + accountKey = accountKey, + statusKey = statusKey, + ) + + override fun searchStatus(query: String): RemoteLoader = + TumblrTaggedTimelineLoader( + service = service, + accountKey = accountKey, + tag = query.removePrefix("#"), + ) + + override fun searchUser(query: String): RemoteLoader = + TumblrBlogProfileLoader( + service = service, + accountKey = accountKey, + query = query, + ) + + override fun discoverUsers(): RemoteLoader = notSupported() + + override fun discoverStatuses(): RemoteLoader = notSupported() + + override fun discoverHashtags(): RemoteLoader = notSupported() + + override fun following(userKey: MicroBlogKey): RemoteLoader = + TumblrFollowingLoader( + service = service, + accountKey = accountKey, + ) + + override fun fans(userKey: MicroBlogKey): RemoteLoader = + TumblrFollowersLoader( + service = service, + accountKey = accountKey, + blogKey = userKey, + ) + + override fun profileTabs(userKey: MicroBlogKey): ImmutableList = + persistentListOf( + ProfileTab( + name = UiStrings.Posts, + loader = userTimeline(userKey, mediaOnly = false), + ), + ProfileTab( + name = UiStrings.Media, + displayType = ProfileTab.DisplayType.Gallery, + loader = userTimeline(userKey, mediaOnly = true), + ), + ) + + override suspend fun compose( + data: ComposeData, + progress: () -> Unit, + ) { + require(data.poll == null) { "Tumblr poll compose is not supported" } + val credential = credentialFlow.first() + val referenceStatus = data.referenceStatus?.composeStatus + if (referenceStatus is ComposeStatus.Quote || referenceStatus is ComposeStatus.Reply) { + require(data.medias.isEmpty()) { "Tumblr reblog compose media is not supported" } + val comment = data.content.trim() + require(comment.isNotEmpty()) { "Tumblr reblog comment is empty" } + reblogWithComment( + credential = credential, + statusKey = referenceStatus.statusKey, + comment = comment, + state = data.visibility.toTumblrState(), + ) + return + } + val media = + data.medias + .take(TUMBLR_MAX_MEDIA_COUNT) + .mapIndexed { index, media -> + val npfMedia = + TumblrNpfMedia( + identifier = "media$index", + type = media.file.mimeType, + ) + val file = media.file.toTumblrComposeMediaFile() + progress() + Triple(npfMedia, file, media.altText) + } + val content = + buildList { + addAll( + data.content + .chunked(TUMBLR_TEXT_BLOCK_MAX_LENGTH) + .filter { it.isNotBlank() } + .map { text -> + TumblrNpfBlock( + type = "text", + text = text, + ) + }, + ) + media.forEach { (npfMedia, _, altText) -> + val blockType = npfMedia.type.toNpfBlockType() + add( + TumblrNpfBlock( + type = blockType, + media = listOf(npfMedia), + altText = altText?.take(TUMBLR_TEXT_BLOCK_MAX_LENGTH), + ), + ) + } + } + require(content.isNotEmpty()) { "Tumblr post content is empty" } + service.createPost( + blogIdentifier = credential.blogIdentifier, + request = + TumblrCreatePostRequest( + content = content, + state = data.visibility.toTumblrState(), + ), + media = media.map { (npfMedia, file, _) -> npfMedia to file }, + ) + } + + override fun composeConfig(type: ComposeType): ComposeConfig = + ComposeConfig( + text = ComposeConfig.Text(TUMBLR_TEXT_BLOCK_MAX_LENGTH * 10), + media = + ComposeConfig + .Media( + maxCount = TUMBLR_MAX_MEDIA_COUNT, + canSensitive = false, + altTextMaxLength = TUMBLR_TEXT_BLOCK_MAX_LENGTH, + allowMediaOnly = true, + ).takeUnless { type == ComposeType.Quote || type == ComposeType.Reply }, + visibility = ComposeConfig.Visibility, + ) + + override suspend fun handle( + event: PostEvent, + updater: DatabaseUpdater, + ) { + require(event is PostEvent.Tumblr) + when (event) { + is PostEvent.Tumblr.Like -> handleLike(event) + is PostEvent.Tumblr.Repost -> handleRepost(event) + } + } + + private suspend fun handleLike(event: PostEvent.Tumblr.Like) { + val parts = event.postKey.toTumblrPostKeyParts() + val post = service.post(parts.blogName, parts.postId) ?: error("Tumblr post not found: ${event.postKey}") + val key = post.reblogKey ?: error("Tumblr reblog key is missing") + if (event.liked) { + service.unlike(parts.postId, key) + } else { + service.like(parts.postId, key) + } + } + + private suspend fun handleRepost(event: PostEvent.Tumblr.Repost) { + if (event.reposted) { + return + } + val parts = event.postKey.toTumblrPostKeyParts() + val post = service.post(parts.blogName, parts.postId) ?: error("Tumblr post not found: ${event.postKey}") + val key = post.reblogKey ?: error("Tumblr reblog key is missing") + val credential = credentialFlow.first() + service.reblog( + blogIdentifier = credential.blogIdentifier, + postId = parts.postId, + reblogKey = key, + ) + } + + private suspend fun reblogWithComment( + credential: TumblrCredential, + statusKey: MicroBlogKey, + comment: String, + state: String, + ) { + val parts = statusKey.toTumblrPostKeyParts() + val post = service.post(parts.blogName, parts.postId) ?: error("Tumblr post not found: $statusKey") + val key = post.reblogKey ?: error("Tumblr reblog key is missing") + service.reblog( + blogIdentifier = credential.blogIdentifier, + postId = parts.postId, + reblogKey = key, + comment = comment, + state = state, + ) + } +} + +private fun TumblrDataSource.tumblrHomeCandidate(title: UiText = UiStrings.Home.asText()): TimelineCandidate<*> = + CommonTimelineSpecs.home.candidate( + data = TimelineSpec.AccountBasedData(accountKey), + icon = IconType.Material(UiIcon.Tumblr), + title = title, + ) + +private fun TimelineCandidate<*>.withFullWidthPost(): TimelineCandidate<*> = + copy( + appearancePatch = + (appearancePatch ?: AppearancePatch.EMPTY) + .set(AppearanceKeys.FullWidthPost, true), + ) + +private fun String?.toNpfBlockType(): String = + when { + this?.startsWith("video/") == true -> "video" + this?.startsWith("audio/") == true -> "audio" + else -> "image" + } + +private fun UiTimelineV2.Post.Visibility.toTumblrState(): String = + when (this) { + UiTimelineV2.Post.Visibility.Public, + UiTimelineV2.Post.Visibility.Home, + -> "published" + + UiTimelineV2.Post.Visibility.Followers, + UiTimelineV2.Post.Visibility.Specified, + UiTimelineV2.Post.Visibility.Channel, + -> "private" + } diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrKeys.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrKeys.kt new file mode 100644 index 0000000000..02c1f99d9c --- /dev/null +++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrKeys.kt @@ -0,0 +1,49 @@ +package dev.dimension.flare.data.datasource.tumblr + +import dev.dimension.flare.data.platform.TUMBLR_HOST +import dev.dimension.flare.model.MicroBlogKey + +private const val POST_KEY_SEPARATOR = ":" + +public fun tumblrUserKey(blogName: String): MicroBlogKey = + MicroBlogKey( + id = blogName.normalizedTumblrBlogName(), + host = TUMBLR_HOST, + ) + +public fun tumblrPostKey( + blogName: String, + postId: String, +): MicroBlogKey = + MicroBlogKey( + id = "${blogName.normalizedTumblrBlogName()}$POST_KEY_SEPARATOR$postId", + host = TUMBLR_HOST, + ) + +internal data class TumblrPostKeyParts( + val blogName: String, + val postId: String, +) + +internal fun MicroBlogKey.toTumblrPostKeyParts(): TumblrPostKeyParts { + val blogName = id.substringBefore(POST_KEY_SEPARATOR).normalizedTumblrBlogName() + val postId = id.substringAfter(POST_KEY_SEPARATOR, missingDelimiterValue = id) + return TumblrPostKeyParts(blogName = blogName, postId = postId) +} + +internal fun MicroBlogKey.toTumblrBlogIdentifier(): String = id.normalizedTumblrBlogName() + +internal fun String.normalizedTumblrBlogName(): String = + trim() + .removePrefix("@") + .removePrefix("https://") + .removePrefix("http://") + .removePrefix("www.") + .removeSuffix("/") + .substringBefore(".tumblr.com") + .substringBefore("/") + .lowercase() + +internal fun tumblrBlogUrl(blogName: String): String = "https://${blogName.normalizedTumblrBlogName()}.tumblr.com/" + +internal fun tumblrAvatarUrl(blogName: String): String = "https://api.tumblr.com/v2/blog/${blogName.normalizedTumblrBlogName()}/avatar/512" diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrLoader.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrLoader.kt new file mode 100644 index 0000000000..f27b7da1d9 --- /dev/null +++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrLoader.kt @@ -0,0 +1,70 @@ +package dev.dimension.flare.data.datasource.tumblr + +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 +import dev.dimension.flare.data.network.tumblr.TumblrService +import dev.dimension.flare.model.MicroBlogKey +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 TumblrLoader( + private val service: TumblrService, + private val accountKey: MicroBlogKey, +) : PostLoader, + RelationLoader, + UserLoader { + override val supportedTypes: Set = setOf(RelationActionType.Follow) + + override suspend fun status(statusKey: MicroBlogKey): UiTimelineV2 { + val parts = statusKey.toTumblrPostKeyParts() + return service + .post( + blogIdentifier = parts.blogName, + postId = parts.postId, + )?.toUiTimeline(accountKey) + ?: error("Tumblr post not found: $statusKey") + } + + override suspend fun deleteStatus(statusKey: MicroBlogKey) { + val parts = statusKey.toTumblrPostKeyParts() + service.deletePost( + blogIdentifier = parts.blogName, + postId = parts.postId, + ) + } + + override suspend fun relation(userKey: MicroBlogKey): UiRelation { + val blog = service.blogInfo(userKey.toTumblrBlogIdentifier()) + return UiRelation(following = blog.following == true) + } + + override suspend fun follow(userKey: MicroBlogKey) { + service.follow(tumblrBlogUrl(userKey.id)) + } + + override suspend fun unfollow(userKey: MicroBlogKey) { + service.unfollow(tumblrBlogUrl(userKey.id)) + } + + override suspend fun block(userKey: MicroBlogKey): Unit = throw UnsupportedOperationException("Tumblr block is not supported") + + override suspend fun unblock(userKey: MicroBlogKey): Unit = throw UnsupportedOperationException("Tumblr unblock is not supported") + + override suspend fun mute(userKey: MicroBlogKey): Unit = throw UnsupportedOperationException("Tumblr mute is not supported") + + override suspend fun unmute(userKey: MicroBlogKey): Unit = throw UnsupportedOperationException("Tumblr unmute is not supported") + + override suspend fun userByHandleAndHost(uiHandle: UiHandle): UiProfile = + service + .blogInfo(uiHandle.normalizedRaw.normalizedTumblrBlogName()) + .toUiProfile(accountKey) + + override suspend fun userById(id: String): UiProfile = + service + .blogInfo(id.normalizedTumblrBlogName()) + .toUiProfile(accountKey) +} diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrLoaders.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrLoaders.kt new file mode 100644 index 0000000000..25219c22f1 --- /dev/null +++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrLoaders.kt @@ -0,0 +1,253 @@ +package dev.dimension.flare.data.datasource.tumblr + +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.tumblr.TumblrBlog +import dev.dimension.flare.data.network.tumblr.TumblrPost +import dev.dimension.flare.data.network.tumblr.TumblrService +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.UiTimelineV2 + +private const val DEFAULT_PAGE_SIZE = 20 + +internal class TumblrHomeTimelineLoader( + private val service: TumblrService, + private val accountKey: MicroBlogKey, +) : CacheableRemoteLoader { + override val pagingKey: String = "tumblr_home_$accountKey" + + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend) { + return PagingResult(endOfPaginationReached = true) + } + val offset = (request as? PagingRequest.Append)?.nextKey?.toIntOrNull() + val response = + service.dashboard( + limit = pageSize.coercePageSize(), + offset = offset, + ) + return response.posts.toTimelineResult( + accountKey = accountKey, + nextOffset = (offset ?: 0) + response.posts.size, + pageSize = pageSize, + ) + } +} + +internal class TumblrBlogTimelineLoader( + private val service: TumblrService, + private val accountKey: MicroBlogKey, + private val blogKey: MicroBlogKey, + private val mediaOnly: Boolean = false, +) : CacheableRemoteLoader { + override val pagingKey: String = "tumblr_blog_${blogKey.id}_$accountKey" + + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend) { + return PagingResult(endOfPaginationReached = true) + } + val offset = (request as? PagingRequest.Append)?.nextKey?.toIntOrNull() + val response = + service.blogPosts( + blogIdentifier = blogKey.toTumblrBlogIdentifier(), + limit = pageSize.coercePageSize(), + offset = offset, + ) + val posts = + if (mediaOnly) { + response.posts.filter { it.hasMedia() } + } else { + response.posts + } + return posts.toTimelineResult( + accountKey = accountKey, + nextOffset = (offset ?: 0) + response.posts.size, + pageSize = pageSize, + ) + } +} + +internal class TumblrTaggedTimelineLoader( + private val service: TumblrService, + private val accountKey: MicroBlogKey, + private val tag: String, +) : CacheableRemoteLoader { + override val pagingKey: String = "tumblr_tag_${tag}_$accountKey" + + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend) { + return PagingResult(endOfPaginationReached = true) + } + val before = (request as? PagingRequest.Append)?.nextKey?.toLongOrNull() + val posts = + service.tagged( + tag = tag, + limit = pageSize.coercePageSize(), + beforeTimestampSeconds = before, + ) + val nextKey = + posts + .mapNotNull { it.timestampEpochSeconds } + .minOrNull() + ?.takeIf { posts.size >= pageSize.coercePageSize() } + ?.toString() + return PagingResult( + data = posts.map { it.toUiTimeline(accountKey) }, + nextKey = nextKey, + endOfPaginationReached = nextKey == null, + ) + } +} + +internal class TumblrStatusDetailLoader( + private val service: TumblrService, + private val accountKey: MicroBlogKey, + private val statusKey: MicroBlogKey, +) : CacheableRemoteLoader { + override val pagingKey: String = "tumblr_status_${statusKey.id}_$accountKey" + + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend || request is PagingRequest.Append) { + return PagingResult(endOfPaginationReached = true) + } + val parts = statusKey.toTumblrPostKeyParts() + val post = + service.post( + blogIdentifier = parts.blogName, + postId = parts.postId, + ) + return PagingResult( + data = listOfNotNull(post?.toUiTimeline(accountKey)), + endOfPaginationReached = true, + ) + } +} + +internal class TumblrBlogProfileLoader( + private val service: TumblrService, + private val accountKey: MicroBlogKey, + private val query: String, +) : CacheableRemoteLoader { + override val pagingKey: String = "tumblr_profile_${query}_$accountKey" + + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend || request is PagingRequest.Append) { + return PagingResult(endOfPaginationReached = true) + } + val blog = + runCatching { + service.blogInfo(query.normalizedTumblrBlogName()) + }.getOrNull() + return PagingResult( + data = listOfNotNull(blog?.toUiProfile(accountKey)), + endOfPaginationReached = true, + ) + } +} + +internal class TumblrFollowingLoader( + private val service: TumblrService, + private val accountKey: MicroBlogKey, +) : CacheableRemoteLoader { + override val pagingKey: String = "tumblr_following_$accountKey" + + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend) { + return PagingResult(endOfPaginationReached = true) + } + val offset = (request as? PagingRequest.Append)?.nextKey?.toIntOrNull() + val response = service.following(pageSize.coercePageSize(), offset) + return response.blogs.toProfileResult(accountKey, offset, pageSize) + } +} + +internal class TumblrFollowersLoader( + private val service: TumblrService, + private val accountKey: MicroBlogKey, + private val blogKey: MicroBlogKey, +) : CacheableRemoteLoader { + override val pagingKey: String = "tumblr_followers_${blogKey.id}_$accountKey" + + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend) { + return PagingResult(endOfPaginationReached = true) + } + val offset = (request as? PagingRequest.Append)?.nextKey?.toIntOrNull() + val response = + service.followers( + blogIdentifier = blogKey.toTumblrBlogIdentifier(), + limit = pageSize.coercePageSize(), + offset = offset, + ) + return response.users.toProfileResult(accountKey, offset, pageSize) + } +} + +private fun List.toTimelineResult( + accountKey: MicroBlogKey, + nextOffset: Int, + pageSize: Int, +): PagingResult { + val nextKey = nextOffset.takeIf { size >= pageSize.coercePageSize() }?.toString() + return PagingResult( + data = map { it.toUiTimeline(accountKey) }, + nextKey = nextKey, + endOfPaginationReached = nextKey == null, + ) +} + +private fun List.toProfileResult( + accountKey: MicroBlogKey, + offset: Int?, + pageSize: Int, +): PagingResult { + val nextOffset = (offset ?: 0) + size + val nextKey = nextOffset.takeIf { size >= pageSize.coercePageSize() }?.toString() + return PagingResult( + data = map { it.toUiProfile(accountKey) }, + nextKey = nextKey, + endOfPaginationReached = nextKey == null, + ) +} + +private fun TumblrPost.hasMedia(): Boolean = + photos.isNotEmpty() || + content.any { block -> + when (block.type) { + "image", "video", "audio" -> { + block.media.isNotEmpty() || + block.url != null || + block.poster.isNotEmpty() || + block.thumbnailUrl != null + } + + else -> { + block.media.isNotEmpty() + } + } + } + +private fun Int.coercePageSize(): Int = coerceIn(1, DEFAULT_PAGE_SIZE) diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrMapper.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrMapper.kt new file mode 100644 index 0000000000..ef4ae8b515 --- /dev/null +++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrMapper.kt @@ -0,0 +1,1027 @@ +package dev.dimension.flare.data.datasource.tumblr + +import dev.dimension.flare.data.datasource.microblog.ActionMenu +import dev.dimension.flare.data.datasource.microblog.PostActionFamily +import dev.dimension.flare.data.datasource.microblog.PostEvent +import dev.dimension.flare.data.datasource.microblog.userActionsMenu +import dev.dimension.flare.data.network.tumblr.TumblrBlog +import dev.dimension.flare.data.network.tumblr.TumblrLegacyPhoto +import dev.dimension.flare.data.network.tumblr.TumblrLegacyPhotoSize +import dev.dimension.flare.data.network.tumblr.TumblrNote +import dev.dimension.flare.data.network.tumblr.TumblrNpfBlock +import dev.dimension.flare.data.network.tumblr.TumblrNpfFormatting +import dev.dimension.flare.data.network.tumblr.TumblrNpfFormattingBlog +import dev.dimension.flare.data.network.tumblr.TumblrNpfMedia +import dev.dimension.flare.data.network.tumblr.TumblrPost +import dev.dimension.flare.data.network.tumblr.TumblrTrailItem +import dev.dimension.flare.data.platform.TUMBLR_HOST +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.UiCard +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.UiNumber +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.tumblrLike +import dev.dimension.flare.ui.render.RenderBlockStyle +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.UiDateTime +import dev.dimension.flare.ui.render.UiRichText +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 kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlin.time.Clock +import kotlin.time.Instant + +internal fun TumblrPost.toUiTimeline(accountKey: MicroBlogKey): UiTimelineV2 { + val postBlogName = resolvedBlogName() + val statusKey = tumblrPostKey(postBlogName, resolvedId()) + val baseText = content.collectText().ifBlank { fallbackTextParts().joinToString(separator = "\n") }.trim() + val shouldMoveRootTagsToQuote = baseText.isBlank() && tags.isNotEmpty() && trail.isNotEmpty() + val renderedContent = renderContent(tags = if (shouldMoveRootTagsToQuote) emptyList() else tags) + val ownText = renderedContent.text + val createdAt = createdAtInstant().toUi() + val hasReblogComment = baseText.isNotBlank() || shouldMoveRootTagsToQuote + val referencedTrailPost = + collectReferencedTrailPost( + accountKey = accountKey, + fallbackCreatedAt = createdAt, + fallbackTags = if (shouldMoveRootTagsToQuote) tags else emptyList(), + ) + val quote = referencedTrailPost.takeIf { hasReblogComment } + val quoteMediaKeys = quote?.mediaDeduplicationKeys().orEmpty() + val postMedia = renderedContent.media.filterNot { it.deduplicationKey() in quoteMediaKeys } + val actions = actionMenus(accountKey = accountKey, statusKey = statusKey).toPersistentList() + val post = + UiTimelineV2.Post( + platformType = PlatformType.Tumblr, + images = postMedia.toPersistentList(), + sensitive = false, + contentWarning = null, + user = (blog ?: TumblrBlog(name = postBlogName)).toUiProfile(accountKey), + content = renderedContent.richText.takeUnless { it.isEmpty } ?: summary.orEmpty().toUiPlainText(), + actions = actions, + poll = null, + statusKey = statusKey, + card = renderedContent.card, + createdAt = createdAt, + visibility = UiTimelineV2.Post.Visibility.Public, + references = + trail + .mapNotNull { trailItem -> + val trailPostId = trailItem.post?.id ?: return@mapNotNull null + val trailBlogName = trailItem.blog?.name ?: postBlogName + UiTimelineV2.Post.Reference( + statusKey = tumblrPostKey(trailBlogName, trailPostId), + type = ReferenceType.Retweet, + ) + }.toPersistentList(), + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.Status.Detail( + statusKey = statusKey, + accountType = AccountType.Specific(accountKey), + ), + ), + accountType = AccountType.Specific(accountKey), + itemKey = "tumblr_${statusKey.id}", + ) + val repost = + referencedTrailPost + ?.takeUnless { hasReblogComment } + ?.copy(actions = actions) + return UiTimelineV2.TimelinePostItem( + post = post, + presentation = + UiTimelineV2.PostPresentation( + message = + repost?.let { + repostMessage( + accountKey = accountKey, + statusKey = statusKey, + createdAt = createdAt, + ) + }, + quotes = listOfNotNull(quote).toPersistentList(), + repost = repost, + ), + ) +} + +internal fun TumblrBlog.toUiProfile(accountKey: MicroBlogKey): UiProfile { + val blogName = name.normalizedTumblrBlogName() + val userKey = tumblrUserKey(blogName) + return UiProfile( + key = userKey, + handle = + UiHandle( + raw = blogName, + host = TUMBLR_HOST, + ), + avatar = tumblrAvatarUrl(blogName), + nameInternal = (title ?: blogName).toUiPlainText(), + platformType = PlatformType.Tumblr, + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.Profile.User( + accountType = AccountType.Specific(accountKey), + userKey = userKey, + ), + ), + banner = theme?.headerImage, + description = description?.takeIf { it.isNotBlank() }?.toUiPlainText(), + matrices = + UiProfile.Matrices( + fansCount = followers ?: 0, + followsCount = 0, + statusesCount = totalPosts ?: posts ?: 0, + ), + mark = persistentListOf(), + bottomContent = null, + ) +} + +private fun TumblrPost.repostMessage( + accountKey: MicroBlogKey, + statusKey: MicroBlogKey, + createdAt: UiDateTime, +): UiTimelineV2.Message { + val profile = (blog ?: TumblrBlog(name = resolvedBlogName())).toUiProfile(accountKey) + val accountType = AccountType.Specific(accountKey) + return UiTimelineV2.Message( + user = profile, + statusKey = statusKey, + icon = UiIcon.Retweet, + type = UiTimelineV2.Message.Type.Localized(UiTimelineV2.Message.Type.Localized.MessageId.Repost), + createdAt = createdAt, + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.Profile.User( + accountType = accountType, + userKey = profile.key, + ), + ), + accountType = accountType, + ) +} + +private fun TumblrPost.actionMenus( + accountKey: MicroBlogKey, + statusKey: MicroBlogKey, +): List = + buildList { + add( + ActionMenu.Item( + icon = UiIcon.Reply, + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.Reply), + count = UiNumber(replyActionCount), + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.Compose.Reply( + accountKey = accountKey, + statusKey = statusKey, + ), + ), + actionFamily = PostActionFamily.Reply, + ), + ) + if (canReblog != false && reblogKey != null) { + add( + ActionMenu.Group( + displayItem = + tumblrRepeatableRepostAction( + statusKey = statusKey, + count = reblogActionCount, + accountKey = accountKey, + ), + actions = + listOf( + tumblrRepeatableRepostAction( + statusKey = statusKey, + count = reblogActionCount, + 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, + ), + ), + actionFamily = PostActionFamily.Quote, + ), + ).toPersistentList(), + ), + ) + } + if (canLike != false && reblogKey != null) { + add( + ActionMenu.tumblrLike( + statusKey = statusKey, + liked = liked == true, + count = likeActionCount, + accountKey = accountKey, + ), + ) + } + val overflow = + buildList { + val shareUrl = postUrl ?: shortUrl + if (shareUrl != null) { + add( + ActionMenu.Item( + icon = UiIcon.Share, + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.Share), + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.Status.ShareSheet( + statusKey = statusKey, + accountType = AccountType.Specific(accountKey), + shareUrl = shareUrl, + ), + ), + actionFamily = PostActionFamily.Share, + ), + ) + } + val userKey = tumblrUserKey(resolvedBlogName()) + if (userKey.id == accountKey.id) { + add( + 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( + statusKey = statusKey, + accountType = AccountType.Specific(accountKey), + ), + ), + actionFamily = PostActionFamily.Delete, + ), + ) + } else { + add(ActionMenu.Divider) + addAll( + userActionsMenu( + accountKey = accountKey, + userKey = userKey, + handle = "@${userKey.id}@$TUMBLR_HOST", + ), + ) + add(ActionMenu.Divider) + add( + ActionMenu.Item( + icon = UiIcon.Report, + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.Report), + color = ActionMenu.Item.Color.Red, + clickEvent = ClickEvent.Noop, + actionFamily = PostActionFamily.Report, + ), + ) + } + } + if (overflow.isNotEmpty()) { + add( + ActionMenu.Group( + displayItem = + ActionMenu.Item( + icon = UiIcon.More, + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.More), + actionFamily = null, + ), + actions = overflow.toPersistentList(), + ), + ) + } + } + +private fun tumblrRepeatableRepostAction( + statusKey: MicroBlogKey, + count: Long, + accountKey: MicroBlogKey, +): ActionMenu.Item = + ActionMenu.Item( + icon = UiIcon.Retweet, + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.Retweet), + count = UiNumber(count), + clickEvent = + ClickEvent.event( + accountKey, + PostEvent.Tumblr.Repost( + postKey = statusKey, + reposted = false, + count = count, + accountKey = accountKey, + ), + ), + actionFamily = PostActionFamily.Repost, + ) + +private val TumblrPost.replyActionCount: Long + get() = + replyCount + ?: repliesCount + ?: commentCount + ?: commentsCount + ?: notes.countTypes("reply", "replied", "comment", "commented") + +private val TumblrPost.reblogActionCount: Long + get() = + reblogCount + ?: reblogsCount + ?: notes.countTypes("reblog", "reblogged", "posted") + +private val TumblrPost.likeActionCount: Long + get() = + likeCount + ?: likesCount + ?: notes.countTypes("like", "liked") + +private fun List.countTypes(vararg types: String): Long { + val normalizedTypes = types.toSet() + return count { note -> + val type = + note.type + ?.trim() + ?.lowercase() + ?.replace('-', '_') + normalizedTypes.contains(type) + }.toLong() +} + +internal fun TumblrPost.resolvedId(): String = idString ?: id?.toString() ?: error("Tumblr post id is missing") + +internal fun TumblrPost.resolvedBlogName(): String = + blogName?.normalizedTumblrBlogName() + ?: blog?.name?.normalizedTumblrBlogName() + ?: error("Tumblr blog name is missing") + +private fun TumblrPost.createdAtInstant(): Instant = + timestampEpochSeconds + ?.let(Instant::fromEpochSeconds) + ?: Clock.System.now() + +private data class TumblrRenderedPostContent( + val richText: UiRichText, + val media: List, + val card: UiCard?, +) { + val text: String + get() = richText.innerText.trim() +} + +private data class TumblrNpfRenderedContent( + val renderRuns: List, + val media: List, +) + +private data class TumblrNpfFormattingRange( + val type: String, + val start: Int, + val end: Int, + val url: String?, + val blog: TumblrNpfFormattingBlog?, +) + +private fun TumblrPost.renderContent(tags: List = this.tags): TumblrRenderedPostContent = + renderContent( + content = content, + tags = tags, + fallbackTextParts = fallbackTextParts(), + photos = photos, + ) + +private fun TumblrPost.fallbackTextParts(): List = listOfNotNull(title, body, caption).filter { it.isNotBlank() } + +private fun TumblrTrailItem.renderContent(fallbackTags: List = emptyList()): TumblrRenderedPostContent = + renderContent( + content = content, + tags = tags.ifEmpty { post?.tags.orEmpty() }.ifEmpty { fallbackTags }, + ) + +private fun renderContent( + content: List, + tags: List, + fallbackTextParts: List = emptyList(), + photos: List = emptyList(), +): TumblrRenderedPostContent { + val tagLine = tags.toTumblrTagLine() + val text = + content + .collectText() + .ifBlank { + fallbackTextParts.joinToString(separator = "\n") + }.appendTumblrTagLine(tagLine) + val shouldInlineImages = content.shouldInlineAllImages() + val renderedNpfContent = content.renderNpfContent(inlineImages = shouldInlineImages) + val fallbackTextRuns = + fallbackTextParts.mapNotNull { part -> + part.takeIf { it.isNotBlank() }?.toRenderTextContent() + } + val tagRuns = + tagLine + .takeIf { it.isNotBlank() } + ?.toRenderTextContent() + ?.let(::listOf) + .orEmpty() + val renderRuns = + (renderedNpfContent.renderRuns.ifEmpty { fallbackTextRuns } + tagRuns) + val media = + renderedNpfContent.media + .ifEmpty { + photos.mapNotNull { photo -> + (photo.originalSize ?: photo.altSizes.firstOrNull()) + ?.toImageMedia(photo.caption) + } + }.deduplicate() + return TumblrRenderedPostContent( + richText = + uiRichTextOf( + renderRuns = renderRuns, + raw = text, + innerText = text, + ), + media = media, + card = content.collectCard(), + ) +} + +private fun String.appendTumblrTagLine(tagLine: String): String { + val text = trim() + return when { + tagLine.isBlank() -> text + text.isBlank() -> tagLine + else -> "$text\n$tagLine" + } +} + +private fun List.toTumblrTagLine(): String = + mapNotNull { tag -> + tag + .trim() + .removePrefix("#") + .replace(Regex("\\s+"), " ") + .takeIf { it.isNotBlank() } + ?.let { "#$it" } + }.distinct() + .joinToString(separator = " ") + +private fun List.shouldInlineAllImages(): Boolean { + var hasTextBefore = false + forEachIndexed { index, block -> + if (block.isImageBlockWithMedia()) { + val hasTextAfter = drop(index + 1).any { it.isInlineDecisionTextBlock() } + if (hasTextBefore && hasTextAfter) { + return true + } + } + if (block.isInlineDecisionTextBlock()) { + hasTextBefore = true + } + } + return false +} + +private fun TumblrNpfBlock.isImageBlockWithMedia(): Boolean = type == "image" && toBestImageMedia(altText) != null + +private fun TumblrNpfBlock.isInlineDecisionTextBlock(): Boolean = type == "text" && !text.isNullOrBlank() + +private fun TumblrPost.collectReferencedTrailPost( + accountKey: MicroBlogKey, + fallbackCreatedAt: UiDateTime, + fallbackTags: List, +): UiTimelineV2.Post? = + trail.firstNotNullOfOrNull { trailItem -> + trailItem.toReferencedPost( + accountKey = accountKey, + fallbackCreatedAt = fallbackCreatedAt, + fallbackTags = fallbackTags, + ) + } + +private fun TumblrTrailItem.toReferencedPost( + accountKey: MicroBlogKey, + fallbackCreatedAt: UiDateTime, + fallbackTags: List, +): UiTimelineV2.Post? { + val trailPost = post ?: return null + val trailPostId = trailPost.id ?: return null + val trailBlog = blog ?: return null + val trailBlogName = trailBlog.name.normalizedTumblrBlogName() + val statusKey = tumblrPostKey(trailBlogName, trailPostId) + val content = renderContent(fallbackTags = fallbackTags) + + if (content.text.isBlank() && content.media.isEmpty() && content.card == null) { + return null + } + + return UiTimelineV2.Post( + platformType = PlatformType.Tumblr, + images = content.media.toPersistentList(), + sensitive = false, + contentWarning = null, + user = trailBlog.toUiProfile(accountKey), + content = content.text.toUiPlainText(), + actions = persistentListOf(), + poll = null, + statusKey = statusKey, + card = content.card, + createdAt = fallbackCreatedAt, + visibility = UiTimelineV2.Post.Visibility.Public, + references = persistentListOf(), + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.Status.Detail( + statusKey = statusKey, + accountType = AccountType.Specific(accountKey), + ), + ), + accountType = AccountType.Specific(accountKey), + itemKey = "tumblr_quote_${statusKey.id}", + ) +} + +private fun List.renderNpfContent(inlineImages: Boolean): TumblrNpfRenderedContent { + val renderRuns = mutableListOf() + val media = mutableListOf() + forEach { block -> + when (block.type) { + "image" -> { + val image = block.toBestImageMedia(block.altText) ?: return@forEach + if (inlineImages) { + renderRuns += + RenderContent.BlockImage( + url = image.url, + href = block.url, + ) + } else { + media += image + } + } + + "video" -> { + block + .toVideoMedia() + ?.let(media::add) + } + + "audio" -> { + block.audioUrl()?.let { url -> + media += + UiMedia.Audio( + url = url, + description = block.title, + previewUrl = block.posterUrl(), + ) + } + } + + else -> { + block + .toRenderTextContent() + ?.let(renderRuns::add) + } + } + } + return TumblrNpfRenderedContent( + renderRuns = renderRuns, + media = media, + ) +} + +private fun TumblrNpfBlock.toRenderTextContent(): RenderContent.Text? { + val text = displayText().takeIf { it.isNotBlank() } ?: return null + val baseStyle = + if (type == "link" && url != null) { + RenderTextStyle(link = url) + } else { + RenderTextStyle() + } + return RenderContent.Text( + runs = text.toRenderTextRuns(formatting, baseStyle).toPersistentList(), + block = toRenderBlockStyle(), + ) +} + +private fun String.toRenderTextContent(): RenderContent.Text = + RenderContent.Text( + runs = listOf(RenderRun.Text(this)).toPersistentList(), + ) + +private fun TumblrNpfBlock.displayText(): String = + when (type) { + "text" -> text.orEmpty() + "link" -> title ?: url.orEmpty() + else -> text ?: title.orEmpty() + } + +private fun TumblrNpfBlock.toRenderBlockStyle(): RenderBlockStyle = + when (subtype?.lowercase()) { + "heading1" -> RenderBlockStyle(headingLevel = 1) + "heading2" -> RenderBlockStyle(headingLevel = 2) + "quote", "indented" -> RenderBlockStyle(isBlockQuote = true) + "unordered-list-item", "ordered-list-item" -> RenderBlockStyle(isListItem = true) + "chat" -> RenderBlockStyle() + else -> RenderBlockStyle() + } + +private fun String.toRenderTextRuns( + formatting: List, + baseStyle: RenderTextStyle, +): List { + if (isEmpty()) return emptyList() + val ranges = formatting.toRanges(this) + if (ranges.isEmpty()) { + return listOf(RenderRun.Text(text = this, style = baseStyle)) + } + + val boundaries = + buildSet { + add(0) + add(length) + ranges.forEach { range -> + add(range.start) + add(range.end) + } + }.filter { it in 0..length } + .sorted() + + return boundaries + .zipWithNext() + .mapNotNull { (start, end) -> + if (start >= end) return@mapNotNull null + val segment = substring(start, end) + if (segment.isEmpty()) return@mapNotNull null + val activeRanges = ranges.filter { range -> start >= range.start && start < range.end } + RenderRun.Text( + text = segment, + style = activeRanges.toRenderTextStyle(baseStyle), + ) + }.ifEmpty { + listOf(RenderRun.Text(text = this, style = baseStyle)) + } +} + +private fun List.toRanges(text: String): List = + mapNotNull { formatting -> + val type = formatting.type?.lowercase() ?: return@mapNotNull null + val start = formatting.start?.let(text::safeCharIndex) ?: return@mapNotNull null + val end = formatting.end?.let(text::safeCharIndex) ?: return@mapNotNull null + if (start >= end) return@mapNotNull null + TumblrNpfFormattingRange( + type = type, + start = start, + end = end, + url = formatting.url, + blog = formatting.blog, + ) + } + +private fun List.toRenderTextStyle(baseStyle: RenderTextStyle): RenderTextStyle { + var style = baseStyle + forEach { range -> + style = + when (range.type) { + "bold" -> style.copy(bold = true) + "italic" -> style.copy(italic = true) + "strikethrough" -> style.copy(strikethrough = true) + "small" -> style.copy(small = true) + "link" -> style.copy(link = range.url ?: style.link) + "mention" -> style.copy(link = range.blog?.toTumblrBlogUrl() ?: style.link) + "code" -> style.copy(code = true, monospace = true) + else -> style + } + } + return style +} + +private fun TumblrNpfFormattingBlog.toTumblrBlogUrl(): String? = + url ?: name?.normalizedTumblrBlogName()?.let { "https://www.tumblr.com/$it" } + +private fun String.safeCharIndex(offset: Int): Int { + val direct = offset.coerceIn(0, length) + if (!isInsideSurrogatePair(direct)) { + return direct + } + return charIndexForCodePointOffset(offset) +} + +private fun String.isInsideSurrogatePair(index: Int): Boolean = + index in 1 until length && + this[index - 1].isHighSurrogateChar() && + this[index].isLowSurrogateChar() + +private fun String.charIndexForCodePointOffset(offset: Int): Int { + var charIndex = 0 + var codePointIndex = 0 + while (charIndex < length && codePointIndex < offset) { + val current = this[charIndex] + val next = getOrNull(charIndex + 1) + charIndex += + if (current.isHighSurrogateChar() && next?.isLowSurrogateChar() == true) { + 2 + } else { + 1 + } + codePointIndex++ + } + return charIndex.coerceIn(0, length) +} + +private fun Char.isHighSurrogateChar(): Boolean = this in '\uD800'..'\uDBFF' + +private fun Char.isLowSurrogateChar(): Boolean = this in '\uDC00'..'\uDFFF' + +private fun List.collectText(): String = + buildString { + appendNpfText(this) + }.trim() + +private fun List.appendNpfText(builder: StringBuilder) { + forEach { block -> + when (block.type) { + "text" -> { + val text = block.text + if (!text.isNullOrBlank()) { + if (builder.isNotEmpty()) builder.append('\n') + builder.append(text) + } + } + + "link" -> { + val title = block.title ?: block.url + if (!title.isNullOrBlank()) { + if (builder.isNotEmpty()) builder.append('\n') + builder.append(title) + } + } + + else -> { + val text = block.text ?: block.title + if (!text.isNullOrBlank()) { + if (builder.isNotEmpty()) builder.append('\n') + builder.append(text) + } + } + } + } +} + +private fun List.collectMedia(): List = + buildList { + this@collectMedia.forEach { block -> + when (block.type) { + "image" -> { + block + .toBestImageMedia(block.altText) + ?.let(::add) + } + + "video" -> { + block + .toVideoMedia() + ?.let(::add) + } + + "audio" -> { + block.audioUrl()?.let { url -> + add( + UiMedia.Audio( + url = url, + description = block.title, + previewUrl = block.posterUrl(), + ), + ) + } + } + } + } + } + +private fun List.deduplicate(): List = distinctBy { it.deduplicationKey() } + +private fun UiTimelineV2.Post.mediaDeduplicationKeys(): Set = + buildSet { + images.forEach { media -> + add(media.deduplicationKey()) + } + content.imageUrls.forEach { url -> + add("image:$url") + } + } + +private fun List.collectCard(): UiCard? = + firstNotNullOfOrNull { block -> + when (block.type) { + "link" -> { + val url = block.url ?: return@firstNotNullOfOrNull null + UiCard( + title = block.title ?: url, + description = block.cardDescription(url), + media = block.posterImageMedia(), + url = url, + ) + } + + "video" -> { + if (block.toVideoMedia() != null) { + null + } else { + val url = + block.url + ?: block.embedIframe?.url + ?: block.media.firstNotNullOfOrNull { it.url } + ?: return@firstNotNullOfOrNull null + UiCard( + title = block.title ?: block.provider ?: url, + description = block.cardDescription(url), + media = block.posterImageMedia(), + url = url, + ) + } + } + + else -> { + null + } + } + } + +private fun TumblrNpfBlock.cardDescription(fallbackUrl: String): String = description?.takeIf { it.isNotBlank() } ?: fallbackUrl + +private fun TumblrNpfBlock.toBestImageMedia(description: String?): UiMedia.Image? = + media + .mapNotNull { it.toImageMedia(description) } + .maxWithOrNull( + compareBy { it.width * it.height } + .thenBy { it.width } + .thenBy { it.height }, + ) + +private fun TumblrNpfMedia.toImageMedia(description: String?): UiMedia.Image? { + val url = url ?: return null + return UiMedia.Image( + url = url, + previewUrl = url, + description = description, + width = width?.toFloat() ?: 0f, + height = height?.toFloat() ?: 0f, + sensitive = false, + ) +} + +private fun TumblrNpfBlock.toVideoMedia(): UiMedia.Video? { + val poster = posterUrl() + val fallbackDescription = title ?: description + val blockProvider = provider + val fallbackWidth = width?.toFloat() ?: 0f + val fallbackHeight = height?.toFloat() ?: 0f + return media + .firstNotNullOfOrNull { media -> + media.toVideoMedia( + poster = poster, + provider = blockProvider, + fallbackDescription = fallbackDescription, + fallbackWidth = fallbackWidth, + fallbackHeight = fallbackHeight, + requirePlayableVideoUrl = true, + ) + } ?: toVideoMediaFromBlock( + poster = poster, + provider = blockProvider, + fallbackDescription = fallbackDescription, + fallbackWidth = fallbackWidth, + fallbackHeight = fallbackHeight, + requirePlayableVideoUrl = true, + ) +} + +private fun TumblrNpfMedia.toVideoMedia( + poster: String?, + provider: String?, + fallbackDescription: String?, + fallbackWidth: Float, + fallbackHeight: Float, + requirePlayableVideoUrl: Boolean = false, +): UiMedia.Video? { + val url = url ?: return null + if ( + requirePlayableVideoUrl && + !url.isLikelyPlayableVideoUrl( + type = type, + provider = provider, + ) + ) { + return null + } + return UiMedia.Video( + url = url, + thumbnailUrl = poster ?: url, + description = fallbackDescription, + width = width?.toFloat() ?: fallbackWidth, + height = height?.toFloat() ?: fallbackHeight, + ) +} + +private fun TumblrNpfBlock.toVideoMediaFromBlock( + poster: String?, + provider: String?, + fallbackDescription: String?, + fallbackWidth: Float, + fallbackHeight: Float, + requirePlayableVideoUrl: Boolean = false, +): UiMedia.Video? { + val url = url ?: return null + if ( + requirePlayableVideoUrl && + !url.isLikelyPlayableVideoUrl( + type = null, + provider = provider, + ) + ) { + return null + } + return UiMedia.Video( + url = url, + thumbnailUrl = poster ?: posterUrl() ?: url, + description = title ?: description ?: fallbackDescription, + width = width?.toFloat() ?: fallbackWidth, + height = height?.toFloat() ?: fallbackHeight, + ) +} + +private fun TumblrNpfBlock.posterImageMedia(): UiMedia.Image? = + poster + .firstOrNull() + ?.toImageMedia(null) + ?: posterUrl()?.let { url -> + UiMedia.Image( + url = url, + previewUrl = url, + description = null, + width = 0f, + height = 0f, + sensitive = false, + ) + } + +private fun TumblrNpfBlock.posterUrl(): String? = + thumbnailUrl + ?: poster.firstNotNullOfOrNull { it.url } + +private fun TumblrNpfBlock.audioUrl(): String? = + url + ?: media.firstNotNullOfOrNull { it.url } + +private fun String.isLikelyPlayableVideoUrl( + type: String?, + provider: String?, +): Boolean { + val normalizedType = type?.lowercase() + if ( + provider.equals("tumblr", ignoreCase = true) || + normalizedType?.startsWith("video/") == true + ) { + return true + } + val path = substringBefore('?').substringBefore('#').lowercase() + return path.endsWith(".mp4") || + path.endsWith(".m4v") || + path.endsWith(".mov") || + path.endsWith(".webm") || + path.endsWith(".m3u8") || + contains("va.media.tumblr.com") +} + +private fun TumblrLegacyPhotoSize.toImageMedia(description: String?): UiMedia.Image = + UiMedia.Image( + url = url, + previewUrl = url, + description = description, + width = width?.toFloat() ?: 0f, + height = height?.toFloat() ?: 0f, + sensitive = false, + ) + +private fun UiMedia.deduplicationKey(): String = + when (this) { + is UiMedia.Audio -> "audio:$url" + is UiMedia.Gif -> "gif:$url" + is UiMedia.Image -> "image:$url" + is UiMedia.Video -> "video:$url" + } diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrModels.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrModels.kt new file mode 100644 index 0000000000..c46739f5b3 --- /dev/null +++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrModels.kt @@ -0,0 +1,499 @@ +package dev.dimension.flare.data.network.tumblr + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.put + +@Serializable +internal data class TumblrEnvelope( + val meta: TumblrMeta? = null, + val response: T? = null, + val errors: List? = null, +) + +@Serializable +internal data class TumblrMeta( + val status: Int? = null, + val msg: String? = null, +) + +@Serializable +internal data class TumblrApiError( + val title: String? = null, + val code: Int? = null, + val detail: String? = null, + val message: String? = null, +) + +@Serializable +internal data class TumblrTokenResponse( + @SerialName("access_token") + val accessToken: String, + @SerialName("refresh_token") + val refreshToken: String? = null, + @SerialName("token_type") + val tokenType: String = "bearer", + val scope: String? = null, + @SerialName("expires_in") + val expiresIn: Long? = null, +) + +@Serializable +internal data class TumblrUserInfoResponse( + val user: TumblrUser, +) + +@Serializable +internal data class TumblrUser( + val name: String? = null, + val likes: Long? = null, + val following: Long? = null, + @SerialName("default_post_format") + val defaultPostFormat: String? = null, + val blogs: List = emptyList(), +) + +@Serializable +internal data class TumblrBlogInfoResponse( + val blog: TumblrBlog, +) + +@Serializable +internal data class TumblrBlogPage( + val blogs: List = emptyList(), + @SerialName("total_blogs") + val totalBlogs: Long? = null, +) + +@Serializable +internal data class TumblrFollowerPage( + val users: List = emptyList(), + @SerialName("total_users") + val totalUsers: Long? = null, +) + +@Serializable +internal data class TumblrPostsPage( + val posts: List = emptyList(), + @SerialName("total_posts") + val totalPosts: Long? = null, +) + +@Serializable(with = TumblrActionResponseSerializer::class) +internal data class TumblrActionResponse( + val id: String? = null, + val url: String? = null, + val name: String? = null, + val message: String? = null, + val liked: Boolean? = null, + val following: Boolean? = null, +) + +@Serializable +internal data class TumblrFollowResponse( + val blog: TumblrBlog? = null, +) + +@Serializable +internal data class TumblrPostMutationResponse( + val id: String? = null, + @SerialName("id_string") + val idString: String? = null, + val state: String? = null, + @SerialName("display_text") + val displayText: String? = null, + @SerialName("post_url") + val postUrl: String? = null, + @SerialName("short_url") + val shortUrl: String? = null, + @SerialName("blog_name") + val blogName: String? = null, + @SerialName("reblog_key") + val reblogKey: String? = null, +) + +@Serializable +internal data class TumblrBlog( + val name: String, + val title: String? = null, + val description: String? = null, + val url: String? = null, + val uuid: String? = null, + @SerialName("updated") + val updatedEpochSeconds: Long? = null, + val followers: Long? = null, + @SerialName("total_posts") + val totalPosts: Long? = null, + val posts: Long? = null, + val following: Boolean? = null, + val primary: Boolean? = null, + val theme: TumblrBlogTheme? = null, +) + +@Serializable +internal data class TumblrBlogTheme( + @SerialName("avatar_shape") + val avatarShape: String? = null, + @SerialName("header_image") + val headerImage: String? = null, +) + +@Serializable +internal data class TumblrPost( + val id: Long? = null, + @SerialName("id_string") + val idString: String? = null, + val type: String? = null, + @SerialName("blog_name") + val blogName: String? = null, + @SerialName("blog") + val blog: TumblrBlog? = null, + @SerialName("post_url") + val postUrl: String? = null, + @SerialName("short_url") + val shortUrl: String? = null, + @SerialName("timestamp") + val timestampEpochSeconds: Long? = null, + val date: String? = null, + val summary: String? = null, + val content: List = emptyList(), + val trail: List = emptyList(), + val layout: List = emptyList(), + val title: String? = null, + val body: String? = null, + val caption: String? = null, + val photos: List = emptyList(), + val tags: List = emptyList(), + @SerialName("note_count") + val noteCount: Long? = null, + val notes: List = emptyList(), + @SerialName("reply_count") + val replyCount: Long? = null, + @SerialName("replies_count") + val repliesCount: Long? = null, + @SerialName("comment_count") + val commentCount: Long? = null, + @SerialName("comments_count") + val commentsCount: Long? = null, + @SerialName("reblog_count") + val reblogCount: Long? = null, + @SerialName("reblogs_count") + val reblogsCount: Long? = null, + @SerialName("like_count") + val likeCount: Long? = null, + @SerialName("likes_count") + val likesCount: Long? = null, + @SerialName("reblog_key") + val reblogKey: String? = null, + val liked: Boolean? = null, + @SerialName("can_like") + val canLike: Boolean? = null, + @SerialName("can_reblog") + val canReblog: Boolean? = null, + @SerialName("is_blocks_post_format") + val isBlocksPostFormat: Boolean? = null, +) + +@Serializable +internal data class TumblrNote( + val type: String? = null, +) + +@Serializable +internal data class TumblrTrailItem( + val blog: TumblrBlog? = null, + val content: List = emptyList(), + val layout: List = emptyList(), + val tags: List = emptyList(), + @SerialName("post") + val post: TumblrTrailPost? = null, +) + +@Serializable +internal data class TumblrTrailPost( + val id: String? = null, + val tags: List = emptyList(), +) + +@Serializable +internal data class TumblrLegacyPhoto( + val caption: String? = null, + @SerialName("original_size") + val originalSize: TumblrLegacyPhotoSize? = null, + @SerialName("alt_sizes") + val altSizes: List = emptyList(), +) + +@Serializable +internal data class TumblrLegacyPhotoSize( + val url: String, + val width: Int? = null, + val height: Int? = null, +) + +@Serializable +internal data class TumblrCreatePostRequest( + val content: List, + val layout: List? = null, + val state: String? = null, + @SerialName("publish_on") + val publishOn: String? = null, + val date: String? = null, + val tags: String? = null, + @SerialName("source_url") + val sourceUrl: String? = null, + @SerialName("send_to_twitter") + val sendToTwitter: Boolean? = null, + @SerialName("is_private") + val isPrivate: Boolean? = null, + val slug: String? = null, + @SerialName("interactability_reblog") + val interactabilityReblog: String? = null, + @SerialName("parent_tumblelog_uuid") + val parentTumblelogUuid: String? = null, + @SerialName("parent_post_id") + val parentPostId: String? = null, + @SerialName("reblog_key") + val reblogKey: String? = null, + @SerialName("hide_trail") + val hideTrail: Boolean? = null, + @SerialName("exclude_trail_items") + val excludeTrailItems: List? = null, +) + +@Serializable(with = TumblrNpfBlockSerializer::class) +internal data class TumblrNpfBlock( + val type: String? = null, + val subtype: String? = null, + val text: String? = null, + val formatting: List = emptyList(), + val title: String? = null, + val url: String? = null, + val description: String? = null, + val provider: String? = null, + val media: List = emptyList(), + val poster: List = emptyList(), + @SerialName("alt_text") + val altText: String? = null, + @SerialName("thumbnail_url") + val thumbnailUrl: String? = null, + val width: Int? = null, + val height: Int? = null, + @SerialName("embed_iframe") + val embedIframe: TumblrNpfEmbedIframe? = null, + val raw: JsonObject? = null, +) + +@Serializable +internal data class TumblrNpfFormatting( + val type: String? = null, + val start: Int? = null, + val end: Int? = null, + val url: String? = null, + val blog: TumblrNpfFormattingBlog? = null, + val hex: String? = null, +) + +@Serializable +internal data class TumblrNpfFormattingBlog( + val name: String? = null, + val url: String? = null, + val uuid: String? = null, +) + +@Serializable +internal data class TumblrNpfMedia( + val identifier: String? = null, + val type: String? = null, + val url: String? = null, + val width: Int? = null, + val height: Int? = null, +) + +@Serializable +internal data class TumblrNpfEmbedIframe( + val url: String? = null, +) + +@Serializable +internal data class TumblrNpfLayout( + val type: String? = null, +) + +internal object TumblrNpfBlockSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("TumblrNpfBlock") + + override fun deserialize(decoder: Decoder): TumblrNpfBlock { + val input = decoder as? JsonDecoder ?: return TumblrNpfBlock() + val obj = input.decodeJsonElement() as? JsonObject ?: return TumblrNpfBlock() + return TumblrNpfBlock( + type = obj.stringOrNull("type"), + subtype = obj.stringOrNull("subtype"), + text = obj.stringOrNull("text"), + formatting = + obj + .arrayOrNull("formatting") + ?.mapNotNull { element -> + runCatching { + input.json.decodeFromJsonElement(element) + }.getOrNull() + }.orEmpty(), + title = obj.stringOrNull("title"), + url = obj.stringOrNull("url"), + description = obj.stringOrNull("description"), + provider = obj.stringOrNull("provider"), + media = obj.mediaList("media"), + poster = obj.mediaList("poster"), + altText = obj.stringOrNull("alt_text"), + thumbnailUrl = obj.stringOrNull("thumbnail_url"), + width = obj.intOrNull("width"), + height = obj.intOrNull("height"), + embedIframe = obj.objectOrNull("embed_iframe")?.let { TumblrNpfEmbedIframe(url = it.stringOrNull("url")) }, + raw = obj, + ) + } + + override fun serialize( + encoder: Encoder, + value: TumblrNpfBlock, + ) { + val output = encoder as? JsonEncoder ?: return + output.encodeJsonElement( + buildJsonObject { + value.type?.let { put("type", it) } + value.subtype?.let { put("subtype", it) } + value.text?.let { put("text", it) } + if (value.formatting.isNotEmpty()) { + put("formatting", output.json.encodeToJsonElement(value.formatting)) + } + value.title?.let { put("title", it) } + value.url?.let { put("url", it) } + value.description?.let { put("description", it) } + value.provider?.let { put("provider", it) } + if (value.media.isNotEmpty()) { + put("media", value.media.toNpfMediaJsonElement(blockType = value.type)) + } + if (value.poster.isNotEmpty()) { + put("poster", value.poster.toNpfMediaJsonElement(blockType = value.type)) + } + value.altText?.let { put("alt_text", it) } + value.thumbnailUrl?.let { put("thumbnail_url", it) } + value.width?.let { put("width", it) } + value.height?.let { put("height", it) } + value.embedIframe?.url?.let { url -> + put( + "embed_iframe", + buildJsonObject { + put("url", url) + }, + ) + } + }, + ) + } +} + +private fun List.toNpfMediaJsonElement(blockType: String?) = + if (blockType == "video" || blockType == "audio") { + tumblrNpfMediaJsonObject(first()) + } else { + JsonArray(map { tumblrNpfMediaJsonObject(it) }) + } + +private fun tumblrNpfMediaJsonObject(media: TumblrNpfMedia): JsonObject = + buildJsonObject { + media.identifier?.let { put("identifier", it) } + media.type?.let { put("type", it) } + media.url?.let { put("url", it) } + media.width?.let { put("width", it) } + media.height?.let { put("height", it) } + } + +private fun JsonObject.mediaList(name: String): List = + when (val value = get(name)) { + is JsonObject -> { + listOf(value.toTumblrNpfMedia()) + } + + is JsonArray -> { + value.mapNotNull { element -> + (element as? JsonObject)?.toTumblrNpfMedia() + ?: (element as? JsonPrimitive)?.contentOrNull?.let { TumblrNpfMedia(url = it) } + } + } + + is JsonPrimitive -> { + value.contentOrNull?.let { listOf(TumblrNpfMedia(url = it)) }.orEmpty() + } + + else -> { + emptyList() + } + } + +private fun JsonObject.toTumblrNpfMedia(): TumblrNpfMedia = + TumblrNpfMedia( + identifier = stringOrNull("identifier"), + type = stringOrNull("type"), + url = stringOrNull("url"), + width = intOrNull("width"), + height = intOrNull("height"), + ) + +private fun JsonObject.objectOrNull(name: String): JsonObject? = get(name) as? JsonObject + +private fun JsonObject.arrayOrNull(name: String): JsonArray? = get(name) as? JsonArray + +private fun JsonObject.stringOrNull(name: String): String? = (get(name) as? JsonPrimitive)?.contentOrNull + +private fun JsonObject.intOrNull(name: String): Int? = (get(name) as? JsonPrimitive)?.intOrNull + +internal object TumblrActionResponseSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("TumblrActionResponse") + + override fun deserialize(decoder: Decoder): TumblrActionResponse { + val input = decoder as? JsonDecoder ?: return TumblrActionResponse() + val obj = input.decodeJsonElement() as? JsonObject ?: return TumblrActionResponse() + return TumblrActionResponse( + id = (obj["id"] as? JsonPrimitive)?.contentOrNull, + url = (obj["url"] as? JsonPrimitive)?.contentOrNull, + name = (obj["name"] as? JsonPrimitive)?.contentOrNull, + message = (obj["message"] as? JsonPrimitive)?.contentOrNull, + liked = (obj["liked"] as? JsonPrimitive)?.booleanOrNull, + following = (obj["following"] as? JsonPrimitive)?.booleanOrNull, + ) + } + + override fun serialize( + encoder: Encoder, + value: TumblrActionResponse, + ) { + val output = encoder as? JsonEncoder ?: return + output.encodeJsonElement( + buildJsonObject { + value.id?.let { put("id", it) } + value.url?.let { put("url", it) } + value.name?.let { put("name", it) } + value.message?.let { put("message", it) } + value.liked?.let { put("liked", it) } + value.following?.let { put("following", it) } + }, + ) + } +} diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrPlatformDetector.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrPlatformDetector.kt new file mode 100644 index 0000000000..1283c5863f --- /dev/null +++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrPlatformDetector.kt @@ -0,0 +1,23 @@ +package dev.dimension.flare.data.network.tumblr + +import dev.dimension.flare.data.network.nodeinfo.NodeData +import dev.dimension.flare.data.network.nodeinfo.PlatformDetector +import dev.dimension.flare.data.platform.TUMBLR_HOST +import dev.dimension.flare.data.platform.TUMBLR_WEB_HOST +import dev.dimension.flare.model.PlatformType + +internal data object TumblrPlatformDetector : PlatformDetector { + override val priority: Int = 80 + + override suspend fun detect(host: String): NodeData? { + if (!TUMBLR_HOST.equals(host, ignoreCase = true) && !TUMBLR_WEB_HOST.equals(host, ignoreCase = true)) { + return null + } + return NodeData( + host = TUMBLR_HOST, + platformType = PlatformType.Tumblr, + software = PlatformType.Tumblr.name, + compatibleMode = false, + ) + } +} diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrResources.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrResources.kt new file mode 100644 index 0000000000..1bccfb23a9 --- /dev/null +++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrResources.kt @@ -0,0 +1,162 @@ +package dev.dimension.flare.data.network.tumblr + +import de.jensklingenberg.ktorfit.http.Body +import de.jensklingenberg.ktorfit.http.Field +import de.jensklingenberg.ktorfit.http.FormUrlEncoded +import de.jensklingenberg.ktorfit.http.GET +import de.jensklingenberg.ktorfit.http.Header +import de.jensklingenberg.ktorfit.http.Multipart +import de.jensklingenberg.ktorfit.http.POST +import de.jensklingenberg.ktorfit.http.Path +import de.jensklingenberg.ktorfit.http.Query +import io.ktor.client.request.forms.MultiPartFormDataContent + +internal interface TumblrAuthResources { + @POST("oauth2/token") + @FormUrlEncoded + suspend fun requestToken( + @Field("grant_type") grantType: String, + @Field("client_id") clientId: String, + @Field("client_secret") clientSecret: String, + @Field("redirect_uri") redirectUri: String, + @Field("code") code: String, + ): TumblrTokenResponse + + @POST("oauth2/token") + @FormUrlEncoded + suspend fun refreshToken( + @Field("grant_type") grantType: String, + @Field("client_id") clientId: String, + @Field("client_secret") clientSecret: String, + @Field("refresh_token") refreshToken: String, + ): TumblrTokenResponse +} + +internal interface TumblrResources { + @GET("user/info") + suspend fun userInfo( + @Header("Authorization") authorization: String, + ): TumblrEnvelope + + @GET("user/dashboard") + suspend fun dashboard( + @Header("Authorization") authorization: String, + @Query("limit") limit: Int, + @Query("offset") offset: Int? = null, + @Query("type") type: String? = null, + @Query("since_id") sinceId: String? = null, + @Query("npf") npf: Boolean = true, + @Query("reblog_info") reblogInfo: Boolean = true, + @Query("notes_info") notesInfo: Boolean = true, + ): TumblrEnvelope + + @GET("blog/{blogIdentifier}/posts") + suspend fun blogPosts( + @Header("Authorization") authorization: String, + @Path("blogIdentifier") blogIdentifier: String, + @Query("limit") limit: Int, + @Query("offset") offset: Int? = null, + @Query("id") postId: String? = null, + @Query("tag") tag: String? = null, + @Query("before") beforeTimestampSeconds: Long? = null, + @Query("npf") npf: Boolean = true, + @Query("reblog_info") reblogInfo: Boolean = true, + @Query("notes_info") notesInfo: Boolean = true, + @Query("filter") filter: String? = null, + ): TumblrEnvelope + + @GET("tagged") + suspend fun tagged( + @Header("Authorization") authorization: String, + @Query("tag") tag: String, + @Query("limit") limit: Int, + @Query("before") beforeTimestampSeconds: Long? = null, + @Query("npf") npf: Boolean = true, + ): TumblrEnvelope> + + @GET("blog/{blogIdentifier}/info") + suspend fun blogInfo( + @Header("Authorization") authorization: String, + @Path("blogIdentifier") blogIdentifier: String, + ): TumblrEnvelope + + @GET("user/following") + suspend fun following( + @Header("Authorization") authorization: String, + @Query("limit") limit: Int, + @Query("offset") offset: Int? = null, + ): TumblrEnvelope + + @GET("blog/{blogIdentifier}/followers") + suspend fun followers( + @Header("Authorization") authorization: String, + @Path("blogIdentifier") blogIdentifier: String, + @Query("limit") limit: Int, + @Query("offset") offset: Int? = null, + ): TumblrEnvelope + + @POST("user/like") + @FormUrlEncoded + suspend fun like( + @Header("Authorization") authorization: String, + @Field("id") postId: String, + @Field("reblog_key") reblogKey: String, + ): TumblrEnvelope + + @POST("user/unlike") + @FormUrlEncoded + suspend fun unlike( + @Header("Authorization") authorization: String, + @Field("id") postId: String, + @Field("reblog_key") reblogKey: String, + ): TumblrEnvelope + + @POST("blog/{blogIdentifier}/post/reblog") + @FormUrlEncoded + suspend fun reblog( + @Header("Authorization") authorization: String, + @Path("blogIdentifier") blogIdentifier: String, + @Field("id") postId: String, + @Field("reblog_key") reblogKey: String, + @Field("comment") comment: String? = null, + @Field("state") state: String? = null, + ): TumblrEnvelope + + @POST("user/follow") + @FormUrlEncoded + suspend fun follow( + @Header("Authorization") authorization: String, + @Field("url") blogUrl: String, + ): TumblrEnvelope + + @POST("user/unfollow") + @FormUrlEncoded + suspend fun unfollow( + @Header("Authorization") authorization: String, + @Field("url") blogUrl: String, + ): TumblrEnvelope + + @POST("blog/{blogIdentifier}/post/delete") + @FormUrlEncoded + suspend fun deletePost( + @Header("Authorization") authorization: String, + @Path("blogIdentifier") blogIdentifier: String, + @Field("id") postId: String, + ): TumblrEnvelope + + @POST("blog/{blogIdentifier}/posts") + suspend fun createPost( + @Header("Authorization") authorization: String, + @Path("blogIdentifier") blogIdentifier: String, + @Body request: TumblrCreatePostRequest, + @Header("Content-Type") contentType: String = "application/json", + ): TumblrEnvelope + + @Multipart + @POST("blog/{blogIdentifier}/posts") + suspend fun createPost( + @Header("Authorization") authorization: String, + @Path("blogIdentifier") blogIdentifier: String, + @Body body: MultiPartFormDataContent, + ): TumblrEnvelope +} diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrService.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrService.kt new file mode 100644 index 0000000000..25927208e1 --- /dev/null +++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrService.kt @@ -0,0 +1,309 @@ +package dev.dimension.flare.data.network.tumblr + +import dev.dimension.flare.common.FileItem +import dev.dimension.flare.common.JSON +import dev.dimension.flare.data.network.ktorfit +import dev.dimension.flare.data.platform.TumblrCredential +import io.ktor.client.plugins.api.createClientPlugin +import io.ktor.client.request.forms.MultiPartFormDataContent +import io.ktor.client.request.forms.formData +import io.ktor.http.ContentType +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.serialization.encodeToString +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes + +private const val TUMBLR_API_BASE_URL = "https://api.tumblr.com/v2/" +private const val TUMBLR_USER_AGENT = "Flare/1.0 (+https://github.com/DimensionDev/Flare)" +private const val DEFAULT_PAGE_SIZE = 20 + +private val TumblrHeaderPlugin = + createClientPlugin("TumblrHeaderPlugin") { + onRequest { request, _ -> + request.headers.append(HttpHeaders.UserAgent, TUMBLR_USER_AGENT) + } + } + +internal class TumblrService( + private val credentialFlow: Flow? = null, + private val onCredentialRefreshed: suspend (TumblrCredential) -> Unit = {}, + private val authResources: TumblrAuthResources = tumblrKtorfit().createTumblrAuthResources(), + private val resources: TumblrResources = tumblrKtorfit().createTumblrResources(), +) { + suspend fun requestToken( + clientId: String, + clientSecret: String, + redirectUri: String, + code: String, + ): TumblrTokenResponse = + authResources.requestToken( + grantType = "authorization_code", + clientId = clientId, + clientSecret = clientSecret, + redirectUri = redirectUri, + code = code, + ) + + suspend fun refreshToken( + clientId: String, + clientSecret: String, + refreshToken: String, + ): TumblrTokenResponse = + authResources.refreshToken( + grantType = "refresh_token", + clientId = clientId, + clientSecret = clientSecret, + refreshToken = refreshToken, + ) + + suspend fun userInfo(): TumblrUserInfoResponse = resources.userInfo(authorization()).requiredResponse() + + suspend fun dashboard( + limit: Int, + offset: Int?, + ): TumblrPostsPage = + resources + .dashboard( + authorization = authorization(), + limit = limit.coercePageSize(), + offset = offset, + ).requiredResponse() + + suspend fun blogPosts( + blogIdentifier: String, + limit: Int, + offset: Int?, + ): TumblrPostsPage = + resources + .blogPosts( + authorization = authorization(), + blogIdentifier = blogIdentifier, + limit = limit.coercePageSize(), + offset = offset, + ).requiredResponse() + + suspend fun post( + blogIdentifier: String, + postId: String, + ): TumblrPost? = + resources + .blogPosts( + authorization = authorization(), + blogIdentifier = blogIdentifier, + limit = 1, + postId = postId, + ).requiredResponse() + .posts + .firstOrNull() + + suspend fun tagged( + tag: String, + limit: Int, + beforeTimestampSeconds: Long?, + ): List = + resources + .tagged( + authorization = authorization(), + tag = tag.removePrefix("#"), + limit = limit.coercePageSize(), + beforeTimestampSeconds = beforeTimestampSeconds, + ).requiredResponse() + + suspend fun blogInfo(blogIdentifier: String): TumblrBlog = + resources + .blogInfo( + authorization = authorization(), + blogIdentifier = blogIdentifier, + ).requiredResponse() + .blog + + suspend fun following( + limit: Int, + offset: Int?, + ): TumblrBlogPage = + resources + .following( + authorization = authorization(), + limit = limit.coercePageSize(), + offset = offset, + ).requiredResponse() + + suspend fun followers( + blogIdentifier: String, + limit: Int, + offset: Int?, + ): TumblrFollowerPage = + resources + .followers( + authorization = authorization(), + blogIdentifier = blogIdentifier, + limit = limit.coercePageSize(), + offset = offset, + ).requiredResponse() + + suspend fun like( + postId: String, + reblogKey: String, + ) { + resources.like( + authorization = authorization(), + postId = postId, + reblogKey = reblogKey, + ) + } + + suspend fun unlike( + postId: String, + reblogKey: String, + ) { + resources.unlike( + authorization = authorization(), + postId = postId, + reblogKey = reblogKey, + ) + } + + suspend fun reblog( + blogIdentifier: String, + postId: String, + reblogKey: String, + comment: String? = null, + state: String? = null, + ) { + resources.reblog( + authorization = authorization(), + blogIdentifier = blogIdentifier, + postId = postId, + reblogKey = reblogKey, + comment = comment, + state = state, + ) + } + + suspend fun follow(blogUrl: String) { + resources.follow( + authorization = authorization(), + blogUrl = blogUrl, + ) + } + + suspend fun unfollow(blogUrl: String) { + resources.unfollow( + authorization = authorization(), + blogUrl = blogUrl, + ) + } + + suspend fun deletePost( + blogIdentifier: String, + postId: String, + ) { + resources.deletePost( + authorization = authorization(), + blogIdentifier = blogIdentifier, + postId = postId, + ) + } + + suspend fun createPost( + blogIdentifier: String, + request: TumblrCreatePostRequest, + media: List>, + ) { + val authorization = authorization() + if (media.isEmpty()) { + resources.createPost( + authorization = authorization, + blogIdentifier = blogIdentifier, + request = request, + ) + return + } + + resources.createPost( + authorization = authorization, + blogIdentifier = blogIdentifier, + body = + MultiPartFormDataContent( + formData { + append( + key = "json", + value = JSON.encodeToString(request), + headers = + Headers.build { + append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + }, + ) + media.forEach { (npfMedia, file) -> + append( + key = npfMedia.identifier ?: "media", + value = file.bytes, + headers = + Headers.build { + append( + HttpHeaders.ContentDisposition, + "form-data; name=\"${npfMedia.identifier ?: "media"}\"; filename=\"${file.fileName}\"", + ) + append(HttpHeaders.ContentType, file.mimeType) + }, + ) + } + }, + ), + ) + } + + private suspend fun authorization(): String = "Bearer ${validCredential().accessToken}" + + private suspend fun validCredential(): TumblrCredential { + val current = credentialFlow?.first() ?: error("Tumblr credential is missing") + val expiresAt = current.expiresAtEpochSeconds ?: return current + val now = Clock.System.now().toEpochMilliseconds() / 1000 + if (expiresAt - now > 5.minutes.inWholeSeconds) { + return current + } + val refreshToken = current.refreshToken?.takeIf { it.isNotBlank() } ?: return current + val response = + refreshToken( + clientId = dev.dimension.flare.social.tumblr.TumblrBuildConfig.clientId, + clientSecret = dev.dimension.flare.social.tumblr.TumblrBuildConfig.clientSecret, + refreshToken = refreshToken, + ) + val updated = + current.copy( + accessToken = response.accessToken, + refreshToken = response.refreshToken ?: current.refreshToken, + tokenType = response.tokenType, + scope = response.scope ?: current.scope, + expiresAtEpochSeconds = response.expiresIn?.let { Clock.System.now().epochSeconds + it }, + ) + onCredentialRefreshed(updated) + return updated + } +} + +private fun tumblrKtorfit() = + ktorfit(TUMBLR_API_BASE_URL) { + expectSuccess = true + install(TumblrHeaderPlugin) + } + +private fun TumblrEnvelope.requiredResponse(): T = response ?: error("Tumblr response is missing") + +internal data class ComposeMediaFile( + val bytes: ByteArray, + val fileName: String, + val mimeType: String, +) + +internal suspend fun FileItem.toTumblrComposeMediaFile(): ComposeMediaFile = + ComposeMediaFile( + bytes = readBytes(), + fileName = name ?: "media", + mimeType = mimeType ?: "application/octet-stream", + ) + +private fun Int.coercePageSize(): Int = coerceIn(1, DEFAULT_PAGE_SIZE) diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/platform/TumblrPlatformSpec.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/platform/TumblrPlatformSpec.kt new file mode 100644 index 0000000000..b2e891b386 --- /dev/null +++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/platform/TumblrPlatformSpec.kt @@ -0,0 +1,166 @@ +package dev.dimension.flare.data.platform + +import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource +import dev.dimension.flare.data.datasource.tumblr.TumblrDataSource +import dev.dimension.flare.data.datasource.tumblr.tumblrPostKey +import dev.dimension.flare.data.datasource.tumblr.tumblrUserKey +import dev.dimension.flare.data.model.tab.TimelineSpec +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformDataSourceContext +import dev.dimension.flare.model.PlatformDeepLink +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.presenter.login.LoginPlatformProvider +import dev.dimension.flare.ui.presenter.login.TumblrLoginProvider +import dev.dimension.flare.ui.route.DeeplinkRoute +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.native.HiddenFromObjC + +public const val TUMBLR_HOST: String = "tumblr.com" +public const val TUMBLR_WEB_HOST: String = "www.tumblr.com" + +@HiddenFromObjC +public data object TumblrPlatformSpec : + PlatformSpec, + LoginPlatformProvider by TumblrLoginProvider { + override val type: PlatformType = PlatformType.Tumblr + override val metadata: PlatformTypeMetadata = + PlatformTypeMetadata( + displayName = "Tumblr", + icon = UiIcon.Tumblr, + ) + + override val timelineSpecs: ImmutableList> = + persistentListOf(CommonTimelineSpecs.home) + + override fun deepLinks(accountKey: MicroBlogKey): ImmutableList> = + persistentListOf( + tumblrWebPostDeepLink(accountKey, "https://www.tumblr.com/{blogName}/{id}"), + tumblrSubdomainPostDeepLink(accountKey, "https://{blogname}.tumblr.com/post/{id}"), + tumblrWebProfileDeepLink(accountKey, "https://www.tumblr.com/{blogName}"), + tumblrSubdomainProfileDeepLink(accountKey, "https://{blogname}.tumblr.com"), + ) + + override fun createDataSource(context: PlatformDataSourceContext): MicroblogDataSource = + TumblrDataSource( + accountKey = context.accountKey, + credentialFlow = context.credentialFlow(TumblrCredential.serializer()), + updateCredential = { credential -> + context.updateCredential( + serializer = TumblrCredential.serializer(), + credential = credential, + ) + }, + ) + + override fun guestDataSource( + host: String, + locale: String, + ): MicroblogDataSource = throw UnsupportedOperationException("Tumblr guest data source is not supported") +} + +@Serializable +public data class TumblrCredential( + val accessToken: String, + val refreshToken: String? = null, + val tokenType: String = "bearer", + val scope: String? = null, + val expiresAtEpochSeconds: Long? = null, + val blogIdentifier: String, + val blogName: String, + val blogUrl: String, + val blogUuid: String? = null, + val isPrimary: Boolean = false, +) + +private fun tumblrWebPostDeepLink( + accountKey: MicroBlogKey, + uriPattern: String, +): PlatformDeepLink = + PlatformDeepLink( + uriPattern = uriPattern, + serializer = TumblrWebPostDeepLink.serializer(), + callback = { data -> + DeeplinkRoute.Status.Detail( + accountType = AccountType.Specific(accountKey), + statusKey = tumblrPostKey(data.blogName, data.id), + ) + }, + ) + +private fun tumblrSubdomainPostDeepLink( + accountKey: MicroBlogKey, + uriPattern: String, +): PlatformDeepLink = + PlatformDeepLink( + uriPattern = uriPattern, + serializer = TumblrSubdomainPostDeepLink.serializer(), + matcher = { data -> !data.blogName.equals("www", ignoreCase = true) }, + callback = { data -> + DeeplinkRoute.Status.Detail( + accountType = AccountType.Specific(accountKey), + statusKey = tumblrPostKey(data.blogName, data.id), + ) + }, + ) + +private fun tumblrWebProfileDeepLink( + accountKey: MicroBlogKey, + uriPattern: String, +): PlatformDeepLink = + PlatformDeepLink( + uriPattern = uriPattern, + serializer = TumblrWebProfileDeepLink.serializer(), + callback = { data -> + DeeplinkRoute.Profile.User( + accountType = AccountType.Specific(accountKey), + userKey = tumblrUserKey(data.blogName), + ) + }, + ) + +private fun tumblrSubdomainProfileDeepLink( + accountKey: MicroBlogKey, + uriPattern: String, +): PlatformDeepLink = + PlatformDeepLink( + uriPattern = uriPattern, + serializer = TumblrSubdomainProfileDeepLink.serializer(), + matcher = { data -> !data.blogName.equals("www", ignoreCase = true) }, + callback = { data -> + DeeplinkRoute.Profile.User( + accountType = AccountType.Specific(accountKey), + userKey = tumblrUserKey(data.blogName), + ) + }, + ) + +@Serializable +private data class TumblrWebPostDeepLink( + val blogName: String, + val id: String, +) + +@Serializable +private data class TumblrSubdomainPostDeepLink( + @SerialName("blogname") + val blogName: String, + val id: String, +) + +@Serializable +private data class TumblrWebProfileDeepLink( + val blogName: String, +) + +@Serializable +private data class TumblrSubdomainProfileDeepLink( + @SerialName("blogname") + val blogName: String, +) diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/TumblrLoginProvider.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/TumblrLoginProvider.kt new file mode 100644 index 0000000000..8fc6655f6d --- /dev/null +++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/TumblrLoginProvider.kt @@ -0,0 +1,279 @@ +package dev.dimension.flare.ui.presenter.login + +import dev.dimension.flare.data.datastore.PlatformOAuthPending +import dev.dimension.flare.data.datastore.PlatformOAuthPendingRepository +import dev.dimension.flare.data.network.nodeinfo.PlatformDetector +import dev.dimension.flare.data.network.tumblr.TumblrPlatformDetector +import dev.dimension.flare.data.network.tumblr.TumblrService +import dev.dimension.flare.data.network.tumblr.TumblrTokenResponse +import dev.dimension.flare.data.platform.TUMBLR_HOST +import dev.dimension.flare.data.platform.TumblrCredential +import dev.dimension.flare.data.platform.TumblrPlatformSpec +import dev.dimension.flare.data.repository.AccountService +import dev.dimension.flare.di.koinInject +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.model.PlatformTypeMetadata +import dev.dimension.flare.model.RecommendedInstance +import dev.dimension.flare.social.tumblr.TumblrBuildConfig +import dev.dimension.flare.ui.model.UiAccount +import dev.dimension.flare.ui.model.UiInstance +import dev.dimension.flare.ui.model.UiInstanceMetadata +import dev.dimension.flare.ui.model.UiStrings +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import io.ktor.http.encodeURLParameter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.random.Random +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes + +private const val LOGIN_ACTION = "login" +private const val OAUTH_HOST = "www.tumblr.com" +private const val OAUTH_AUTHORIZE_URL = "https://www.tumblr.com/oauth2/authorize" +private val TUMBLR_SCOPES = listOf("basic", "write", "offline_access") + +public data object TumblrLoginProvider : LoginPlatformProvider { + override val platformType: PlatformType = PlatformType.Tumblr + override val metadata: PlatformTypeMetadata + get() = TumblrPlatformSpec.metadata + override val detector: PlatformDetector = TumblrPlatformDetector + override val methods: List = + listOf( + LoginMethodSpec( + type = LoginMethodType.OAuth, + title = UiStrings.OAuthLogin, + ), + ) + + override fun agreementUrl(host: String): String? = "https://www.tumblr.com/policy/en/terms-of-service" + + override suspend fun recommendInstances(): List = + listOf( + RecommendedInstance( + instance = + UiInstance( + name = "Tumblr", + description = "Blogs, posts, art, and fandoms.", + iconUrl = null, + domain = TUMBLR_HOST, + type = platformType, + bannerUrl = null, + usersCount = 0, + ), + priority = 70, + ), + ) + + override suspend fun instanceMetadata(host: String): UiInstanceMetadata = + throw UnsupportedOperationException("${platformType.name} metadata is not supported") + + override fun createHandler(context: LoginContext): LoginMethodHandler { + require(context.methodType == LoginMethodType.OAuth) { + "Unsupported Tumblr login method: ${context.methodType}" + } + return TumblrOAuthLoginHandler(context) + } +} + +private class TumblrOAuthLoginHandler( + private val context: LoginContext, +) : LoginMethodHandler { + private val accountService: AccountService by koinInject() + private val pendingRepository: PlatformOAuthPendingRepository by koinInject() + private val service = TumblrService() + private val _state = MutableStateFlow(oauthState()) + private val _effects = MutableSharedFlow(extraBufferCapacity = 1) + private val resumeMutex = Mutex() + private var resumeCompleted = false + + override val state: StateFlow = _state + override val effects: Flow = _effects + + override fun updateField( + id: String, + value: String, + ) = Unit + + override suspend fun perform(actionId: String) { + if (actionId != LOGIN_ACTION) return + _state.value = oauthState(loading = true) + resumeMutex.withLock { + resumeCompleted = false + } + runCatching { + require(TumblrBuildConfig.configured) { + "Tumblr OAuth is not configured. Set TUMBLR_CLIENT_ID and TUMBLR_CLIENT_SECRET." + } + val redirectUri = context.redirectUri ?: TumblrBuildConfig.redirectUri + val state = newState() + val authorizeUrl = buildAuthorizeUrl(redirectUri = redirectUri, state = state) + pendingRepository.save( + PlatformOAuthPending( + platformType = PlatformType.Tumblr, + host = OAUTH_HOST, + createdAtEpochMillis = Clock.System.now().toEpochMilliseconds(), + attributes = + mapOf( + "state" to state, + "redirect_uri" to redirectUri, + "expires_in_millis" to 10.minutes.inWholeMilliseconds.toString(), + ), + ), + ) + _effects.emit(LoginEffect.OpenUrl(authorizeUrl)) + }.onFailure { + _state.value = oauthState(error = it.message) + } + } + + override suspend fun resume(value: String) { + resumeMutex.withLock { + if (resumeCompleted) { + return@withLock + } + _state.value = oauthState(loading = true) + runCatching { + val parsedUrl = Url(value) + val code = parsedUrl.parameters["code"] ?: error("Missing Tumblr OAuth code") + val state = parsedUrl.parameters["state"] ?: error("Missing Tumblr OAuth state") + val pending = + pendingRepository + .all(PlatformType.Tumblr) + .firstOrNull { it.attributes["state"] == state } + ?: error("No pending Tumblr OAuth") + val expectedState = pending.attributes.getValue("state") + require(state == expectedState) { + "State mismatch: expected $expectedState, got $state" + } + val redirectUri = pending.attributes["redirect_uri"] ?: TumblrBuildConfig.redirectUri + val token = + service.requestToken( + clientId = TumblrBuildConfig.clientId, + clientSecret = TumblrBuildConfig.clientSecret, + redirectUri = redirectUri, + code = code, + ) + val userInfoService = + TumblrService( + credentialFlow = + kotlinx.coroutines.flow.flowOf( + token.toCredential( + blogIdentifier = "pending", + blogName = "pending", + blogUrl = "https://$TUMBLR_HOST/", + blogUuid = null, + isPrimary = false, + ), + ), + ) + val blogs = userInfoService.userInfo().user.blogs + require(blogs.isNotEmpty()) { "Tumblr account has no blogs" } + val target = context.reloginTarget + blogs.forEach { blog -> + val accountKey = MicroBlogKey(id = blog.name, host = TUMBLR_HOST) + if (target != null && target.accountKey != accountKey) { + return@forEach + } + context.requireReloginAccount(accountKey) + accountService.addAccount( + account = + UiAccount( + accountKey = accountKey, + platformType = PlatformType.Tumblr, + ), + credential = + token.toCredential( + blogIdentifier = blog.name, + blogName = blog.name, + blogUrl = blog.url ?: "https://${blog.name}.tumblr.com/", + blogUuid = blog.uuid, + isPrimary = blog.primary == true, + ), + serializer = TumblrCredential.serializer(), + ) + } + if (target != null && blogs.none { MicroBlogKey(id = it.name, host = TUMBLR_HOST) == target.accountKey }) { + error("Relogin account not found in Tumblr blogs: ${target.accountKey}") + } + pendingRepository.clear(pending) + resumeCompleted = true + context.onSuccess() + }.onFailure { + _state.value = oauthState(error = it.message) + } + } + } + + override fun canResume(value: String): Boolean = + runCatching { + val parsed = Url(value) + parsed.parameters["code"] != null && parsed.parameters["state"] != null + }.getOrDefault(false) + + override fun clear() { + _state.value = oauthState() + } + + private fun oauthState( + loading: Boolean = false, + error: String? = null, + ): LoginFlowState = + LoginFlowState( + actions = + listOf( + LoginAction( + id = LOGIN_ACTION, + label = UiStrings.Login, + enabled = !loading, + ), + ), + loading = loading, + error = error, + ) +} + +private fun buildAuthorizeUrl( + redirectUri: String, + state: String, +): String = + URLBuilder(OAUTH_AUTHORIZE_URL) + .apply { + parameters.append("client_id", TumblrBuildConfig.clientId) + parameters.append("response_type", "code") + parameters.append("scope", TUMBLR_SCOPES.joinToString(" ")) + parameters.append("state", state) + parameters.append("redirect_uri", redirectUri) + }.buildString() + +private fun TumblrTokenResponse.toCredential( + blogIdentifier: String, + blogName: String, + blogUrl: String, + blogUuid: String?, + isPrimary: Boolean, +): TumblrCredential = + TumblrCredential( + accessToken = accessToken, + refreshToken = refreshToken, + tokenType = tokenType, + scope = scope, + expiresAtEpochSeconds = expiresIn?.let { Clock.System.now().toEpochMilliseconds() / 1000 + it }, + blogIdentifier = blogIdentifier, + blogName = blogName, + blogUrl = blogUrl, + blogUuid = blogUuid, + isPrimary = isPrimary, + ) + +private fun newState(): String = + buildString { + append(Clock.System.now().toEpochMilliseconds()) + append('-') + append(Random.nextLong().toString().encodeURLParameter()) + } diff --git a/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrDataSourceTest.kt b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrDataSourceTest.kt new file mode 100644 index 0000000000..4b652c470a --- /dev/null +++ b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrDataSourceTest.kt @@ -0,0 +1,49 @@ +package dev.dimension.flare.data.datasource.tumblr + +import dev.dimension.flare.data.model.appearance.AppearanceKeys +import dev.dimension.flare.data.model.tab.ShortcutSpec +import dev.dimension.flare.data.model.tab.TimelineCandidate +import dev.dimension.flare.data.platform.TumblrCredential +import dev.dimension.flare.model.MicroBlogKey +import kotlinx.coroutines.flow.flowOf +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class TumblrDataSourceTest { + @Test + fun homeTimelineCandidatesUseFullWidthPosts() { + val dataSource = + TumblrDataSource( + accountKey = accountKey, + credentialFlow = flowOf(credential()), + updateCredential = {}, + ) + + assertFullWidthPost(dataSource.defaultTabs.single()) + assertFullWidthPost(dataSource.builtInTimelineTabs.single()) + assertFullWidthPost(assertIs(dataSource.shortcuts.single().target).candidate) + } + + private fun assertFullWidthPost(candidate: TimelineCandidate<*>) { + val patch = assertNotNull(candidate.appearancePatch) + + assertTrue(patch.contains(AppearanceKeys.FullWidthPost)) + assertEquals(true, patch[AppearanceKeys.FullWidthPost]) + } + + private companion object { + val accountKey = MicroBlogKey("staff", "tumblr.com") + + fun credential(): TumblrCredential = + TumblrCredential( + accessToken = "access", + refreshToken = "refresh", + blogIdentifier = "staff", + blogName = "staff", + blogUrl = "https://staff.tumblr.com", + ) + } +} diff --git a/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrKeysTest.kt b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrKeysTest.kt new file mode 100644 index 0000000000..659485788c --- /dev/null +++ b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrKeysTest.kt @@ -0,0 +1,39 @@ +package dev.dimension.flare.data.datasource.tumblr + +import dev.dimension.flare.data.platform.TUMBLR_HOST +import dev.dimension.flare.model.MicroBlogKey +import kotlin.test.Test +import kotlin.test.assertEquals + +class TumblrKeysTest { + @Test + fun blogNamesAreNormalizedForUserKeys() { + assertEquals( + MicroBlogKey(id = "staff", host = TUMBLR_HOST), + tumblrUserKey("https://www.Staff.tumblr.com/post/123"), + ) + assertEquals( + MicroBlogKey(id = "staff", host = TUMBLR_HOST), + tumblrUserKey("@Staff"), + ) + } + + @Test + fun postKeysRoundTripBlogAndPostId() { + val key = tumblrPostKey(blogName = "Staff", postId = "1234567890") + + assertEquals(MicroBlogKey(id = "staff:1234567890", host = TUMBLR_HOST), key) + assertEquals( + TumblrPostKeyParts(blogName = "staff", postId = "1234567890"), + key.toTumblrPostKeyParts(), + ) + } + + @Test + fun blogUrlUsesTumblrSubdomain() { + assertEquals( + "https://staff.tumblr.com/", + tumblrBlogUrl("https://www.Staff.tumblr.com/archive"), + ) + } +} diff --git a/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrMapperTest.kt b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrMapperTest.kt new file mode 100644 index 0000000000..bf1abd9fb1 --- /dev/null +++ b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrMapperTest.kt @@ -0,0 +1,724 @@ +package dev.dimension.flare.data.datasource.tumblr + +import dev.dimension.flare.data.datasource.microblog.ActionMenu +import dev.dimension.flare.data.datasource.microblog.PostActionFamily +import dev.dimension.flare.data.datasource.microblog.PostEvent +import dev.dimension.flare.data.network.tumblr.TumblrBlog +import dev.dimension.flare.data.network.tumblr.TumblrNote +import dev.dimension.flare.data.network.tumblr.TumblrNpfBlock +import dev.dimension.flare.data.network.tumblr.TumblrNpfFormatting +import dev.dimension.flare.data.network.tumblr.TumblrNpfMedia +import dev.dimension.flare.data.network.tumblr.TumblrPost +import dev.dimension.flare.data.network.tumblr.TumblrTrailItem +import dev.dimension.flare.data.network.tumblr.TumblrTrailPost +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.UiMedia +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.postEventOrNull +import dev.dimension.flare.ui.render.RenderContent +import dev.dimension.flare.ui.render.RenderRun +import dev.dimension.flare.ui.route.DeeplinkRoute +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class TumblrMapperTest { + private val accountKey = MicroBlogKey(id = "me", host = "tumblr.com") + + @Test + fun npfTextImageAndLinkAreMappedToTimelinePost() { + val post = + TumblrPost( + idString = "123", + blogName = "Staff", + blog = TumblrBlog(name = "Staff", title = "Tumblr Staff", posts = 10), + postUrl = "https://www.tumblr.com/staff/123", + timestampEpochSeconds = 1234, + content = + listOf( + textBlock("Hello Tumblr"), + imageBlock( + url = "https://64.media.tumblr.com/image.jpg", + width = 800, + height = 600, + altText = "image alt", + ), + linkBlock( + url = "https://example.com/story", + title = "Example Story", + description = "A link card", + posterUrl = "https://example.com/poster.jpg", + ), + ), + reblogKey = "abc123", + noteCount = 7, + liked = false, + canLike = true, + canReblog = true, + ) + + val rendered = assertIs(post.toUiTimeline(accountKey)) + val timelinePost = rendered.post + + assertEquals(PlatformType.Tumblr, timelinePost.platformType) + assertEquals(tumblrPostKey("staff", "123"), timelinePost.statusKey) + assertEquals(AccountType.Specific(accountKey), timelinePost.accountType) + assertEquals("Hello Tumblr\nExample Story", timelinePost.content.raw) + val inlineImageCount = + timelinePost.content.renderRuns + .filterIsInstance() + .size + assertEquals(0, inlineImageCount) + assertEquals("Tumblr Staff", timelinePost.user?.name?.raw) + assertEquals("staff", timelinePost.user?.handle?.raw) + assertEquals(1, timelinePost.images.size) + + val image = assertIs(timelinePost.images.first()) + assertEquals("https://64.media.tumblr.com/image.jpg", image.url) + assertEquals("image alt", image.description) + assertEquals(800f, image.width) + assertEquals(600f, image.height) + + val card = assertNotNull(timelinePost.card) + assertEquals("Example Story", card.title) + assertEquals("A link card", card.description) + assertEquals("https://example.com/story", card.url) + assertEquals("https://example.com/poster.jpg", card.media?.url) + assertEquals(4, timelinePost.actions.size) + } + + @Test + fun actionMenusMatchTwitterStyleReplyRepostQuoteLikeMore() { + val post = + TumblrPost( + idString = "123", + blogName = "staff", + blog = TumblrBlog(name = "staff", title = "Tumblr Staff"), + postUrl = "https://www.tumblr.com/staff/123", + content = listOf(textBlock("Hello")), + reblogKey = "abc123", + noteCount = 7, + notes = + listOf( + TumblrNote(type = "reply"), + TumblrNote(type = "comment"), + TumblrNote(type = "reblog"), + TumblrNote(type = "reblogged"), + TumblrNote(type = "posted"), + TumblrNote(type = "like"), + TumblrNote(type = "liked"), + TumblrNote(type = "like"), + TumblrNote(type = "like"), + TumblrNote(type = "like"), + ), + liked = false, + canLike = true, + canReblog = true, + ) + + val timelinePost = assertIs(post.toUiTimeline(accountKey)).post + val actions = timelinePost.actions + + assertEquals(4, actions.size) + + val reply = assertIs(actions[0]) + assertEquals(PostActionFamily.Reply, reply.actionFamily) + assertEquals(2L, reply.count?.value) + val replyRoute = DeeplinkRoute.parse(assertIs(reply.clickEvent).url) + assertIs(replyRoute) + + val repostGroup = assertIs(actions[1]) + val repostDisplay = assertIs(repostGroup.displayItem) + assertEquals(PostActionFamily.Repost, repostDisplay.actionFamily) + assertEquals(3L, repostDisplay.count?.value) + assertEquals("", repostDisplay.updateKey) + assertIs(repostDisplay.clickEvent.postEventOrNull()?.postEvent) + val repostActions = repostGroup.actions + val repost = assertIs(repostActions[0]) + assertEquals(PostActionFamily.Repost, repost.actionFamily) + assertEquals(3L, repost.count?.value) + assertEquals("", repost.updateKey) + val quote = assertIs(repostActions[1]) + assertEquals(PostActionFamily.Quote, quote.actionFamily) + val quoteRoute = DeeplinkRoute.parse(assertIs(quote.clickEvent).url) + assertIs(quoteRoute) + + val like = assertIs(actions[2]) + assertEquals(PostActionFamily.Like, like.actionFamily) + assertEquals(5L, like.count?.value) + + val more = assertIs(actions[3]) + val overflowFamilies = + more.actions + .filterIsInstance() + .mapNotNull { it.actionFamily } + assertEquals( + listOf( + PostActionFamily.Share, + PostActionFamily.MuteUser, + PostActionFamily.BlockUser, + PostActionFamily.Report, + ), + overflowFamilies, + ) + } + + @Test + fun legacyPhotoIsUsedWhenNpfMediaIsMissing() { + val post = + TumblrPost( + idString = "photo-1", + blogName = "staff", + photos = + listOf( + dev.dimension.flare.data.network.tumblr.TumblrLegacyPhoto( + caption = "legacy alt", + originalSize = + dev.dimension.flare.data.network.tumblr.TumblrLegacyPhotoSize( + url = "https://64.media.tumblr.com/legacy.jpg", + width = 1024, + height = 768, + ), + ), + ), + ) + + val timelinePost = assertIs(post.toUiTimeline(accountKey)).post + val image = assertIs(timelinePost.images.single()) + + assertEquals("https://64.media.tumblr.com/legacy.jpg", image.url) + assertEquals("legacy alt", image.description) + assertEquals(1024f, image.width) + assertEquals(768f, image.height) + } + + @Test + fun linkCardUsesUrlAsDescriptionFallback() { + val post = + TumblrPost( + idString = "link-no-description", + blogName = "staff", + content = + listOf( + linkBlock( + url = "https://example.com/story", + title = "Example Story", + description = "", + posterUrl = "https://example.com/poster.jpg", + ), + ), + ) + + val timelinePost = assertIs(post.toUiTimeline(accountKey)).post + val card = assertNotNull(timelinePost.card) + + assertEquals("Example Story", card.title) + assertEquals("https://example.com/story", card.description) + assertEquals("https://example.com/story", card.url) + } + + @Test + fun independentTagsAreAppendedToContent() { + val post = + TumblrPost( + idString = "tags-1", + blogName = "staff", + content = listOf(textBlock("Hello Tumblr")), + tags = listOf("Tumblr", "#KMP", "two words", " "), + ) + + val timelinePost = assertIs(post.toUiTimeline(accountKey)).post + + assertEquals("Hello Tumblr\n#Tumblr #KMP #two words", timelinePost.content.raw) + } + + @Test + fun videoBlockWithMediaObjectIsMappedToTimelineMedia() { + val post = + TumblrPost( + idString = "video-1", + blogName = "staff", + content = + listOf( + videoBlock( + url = "https://va.media.tumblr.com/tumblr_video.mp4", + posterUrl = "https://64.media.tumblr.com/video-poster.jpg", + width = 1280, + height = 720, + title = "Demo video", + ), + ), + ) + + val timelinePost = assertIs(post.toUiTimeline(accountKey)).post + val video = assertIs(timelinePost.images.single()) + + assertEquals("https://va.media.tumblr.com/tumblr_video.mp4", video.url) + assertEquals("https://64.media.tumblr.com/video-poster.jpg", video.thumbnailUrl) + assertEquals("Demo video", video.description) + assertEquals(1280f, video.width) + assertEquals(720f, video.height) + } + + @Test + fun externalVideoEmbedIsMappedToCardInsteadOfTimelineMedia() { + val post = + TumblrPost( + idString = "video-embed-1", + blogName = "staff", + content = + listOf( + externalVideoBlock( + url = "https://www.youtube.com/watch?v=demo", + posterUrl = "https://img.youtube.com/vi/demo/hqdefault.jpg", + title = "External video", + ), + ), + ) + + val timelinePost = assertIs(post.toUiTimeline(accountKey)).post + + assertEquals(0, timelinePost.images.size) + val card = assertNotNull(timelinePost.card) + assertEquals("External video", card.title) + assertEquals("https://www.youtube.com/watch?v=demo", card.url) + assertEquals("https://img.youtube.com/vi/demo/hqdefault.jpg", card.media?.url) + } + + @Test + fun imageBlockUsesBestMediaVariantOnly() { + val post = + TumblrPost( + idString = "photo-variant-1", + blogName = "staff", + content = + listOf( + imageBlock( + url = "https://64.media.tumblr.com/small.jpg", + width = 540, + height = 405, + altText = "image alt", + additionalMedia = + listOf( + TestImageMedia( + url = "https://64.media.tumblr.com/large.jpg", + width = 1280, + height = 960, + ), + ), + ), + ), + ) + + val timelinePost = assertIs(post.toUiTimeline(accountKey)).post + val image = assertIs(timelinePost.images.single()) + + assertEquals("https://64.media.tumblr.com/large.jpg", image.url) + assertEquals(1280f, image.width) + assertEquals(960f, image.height) + } + + @Test + fun allImagesAreInlineWhenAnyImageAppearsBetweenTextBlocks() { + val post = + TumblrPost( + idString = "inline-images-1", + blogName = "staff", + content = + listOf( + imageBlock( + url = "https://64.media.tumblr.com/leading.jpg", + width = 320, + height = 240, + altText = "leading", + ), + textBlock("Before"), + imageBlock( + url = "https://64.media.tumblr.com/middle.jpg", + width = 640, + height = 480, + altText = "middle", + ), + textBlock("After"), + imageBlock( + url = "https://64.media.tumblr.com/trailing.jpg", + width = 800, + height = 600, + altText = "trailing", + ), + ), + ) + + val timelinePost = assertIs(post.toUiTimeline(accountKey)).post + val inlineImages = timelinePost.content.renderRuns.filterIsInstance() + + assertEquals("Before\nAfter", timelinePost.content.raw) + assertEquals(0, timelinePost.images.size) + assertEquals( + listOf( + "https://64.media.tumblr.com/leading.jpg", + "https://64.media.tumblr.com/middle.jpg", + "https://64.media.tumblr.com/trailing.jpg", + ), + inlineImages.map { it.url }, + ) + } + + @Test + fun textFormattingIsMappedToRichTextRuns() { + val post = + TumblrPost( + idString = "rich-text-1", + blogName = "staff", + content = + listOf( + textBlock( + text = "Bold italic link", + formatting = + listOf( + TumblrNpfFormatting(type = "bold", start = 0, end = 4), + TumblrNpfFormatting(type = "italic", start = 4, end = 11), + TumblrNpfFormatting(type = "link", start = 11, end = 16, url = "https://example.com"), + ), + ), + ), + ) + + val timelinePost = assertIs(post.toUiTimeline(accountKey)).post + val textContent = assertIs(timelinePost.content.renderRuns.single()) + val runs = textContent.runs.filterIsInstance() + + assertEquals("Bold", runs[0].text) + assertTrue(runs[0].style.bold) + assertEquals(" italic", runs[1].text) + assertTrue(runs[1].style.italic) + assertEquals(" link", runs[2].text) + assertEquals("https://example.com", runs[2].style.link) + } + + @Test + fun reblogWithCommentMapsOriginalTrailPostAsQuote() { + val post = + TumblrPost( + idString = "reblog-1", + blogName = "me", + blog = TumblrBlog(name = "me", title = "My Blog"), + content = listOf(textBlock("My reblog comment")), + trail = + listOf( + TumblrTrailItem( + blog = TumblrBlog(name = "original", title = "Original Blog"), + post = TumblrTrailPost(id = "original-1"), + content = + listOf( + textBlock("Original text"), + imageBlock( + url = "https://64.media.tumblr.com/original.jpg", + width = 640, + height = 480, + altText = "original alt", + ), + ), + ), + ), + ) + + val rendered = assertIs(post.toUiTimeline(accountKey)) + val quote = rendered.presentation.quotes.single() + + assertEquals("My reblog comment", rendered.post.content.raw) + assertEquals(tumblrPostKey("original", "original-1"), quote.statusKey) + assertEquals("Original Blog", quote.user?.name?.raw) + assertEquals("Original text", quote.content.raw) + val quoteImage = assertIs(quote.images.single()) + assertEquals("https://64.media.tumblr.com/original.jpg", quoteImage.url) + val reference = rendered.post.references.single() + assertEquals( + ReferenceType.Retweet, + reference.type, + ) + } + + @Test + fun reblogWithCommentAppendsOriginalTrailTagsToQuote() { + val post = + TumblrPost( + idString = "reblog-tags", + blogName = "me", + blog = TumblrBlog(name = "me", title = "My Blog"), + content = listOf(textBlock("My reblog comment")), + trail = + listOf( + TumblrTrailItem( + blog = TumblrBlog(name = "original", title = "Original Blog"), + post = TumblrTrailPost(id = "original-tags", tags = listOf("fallback")), + content = listOf(textBlock("Original text")), + tags = listOf("Original Tag", "#NPF"), + ), + ), + ) + + val rendered = assertIs(post.toUiTimeline(accountKey)) + val quote = rendered.presentation.quotes.single() + + assertEquals("Original text\n#Original Tag #NPF", quote.content.raw) + } + + @Test + fun reblogWithCommentFallsBackToTrailPostTagsForQuote() { + val post = + TumblrPost( + idString = "reblog-post-tags", + blogName = "me", + blog = TumblrBlog(name = "me", title = "My Blog"), + content = listOf(textBlock("My reblog comment")), + trail = + listOf( + TumblrTrailItem( + blog = TumblrBlog(name = "original", title = "Original Blog"), + post = TumblrTrailPost(id = "original-post-tags", tags = listOf("Post Tag")), + content = listOf(textBlock("Original text")), + ), + ), + ) + + val rendered = assertIs(post.toUiTimeline(accountKey)) + val quote = rendered.presentation.quotes.single() + + assertEquals("Original text\n#Post Tag", quote.content.raw) + } + + @Test + fun reblogWithCommentDoesNotDuplicateQuoteMediaOnRootPost() { + val post = + TumblrPost( + idString = "reblog-duplicate-image", + blogName = "me", + blog = TumblrBlog(name = "me", title = "My Blog"), + content = + listOf( + textBlock("My reblog comment"), + imageBlock( + url = "https://64.media.tumblr.com/original.jpg", + width = 640, + height = 480, + altText = "original alt", + ), + ), + trail = + listOf( + TumblrTrailItem( + blog = TumblrBlog(name = "original", title = "Original Blog"), + post = TumblrTrailPost(id = "original-duplicate-image"), + content = + listOf( + imageBlock( + url = "https://64.media.tumblr.com/original.jpg", + width = 640, + height = 480, + altText = "original alt", + ), + ), + ), + ), + ) + + val rendered = assertIs(post.toUiTimeline(accountKey)) + val quote = rendered.presentation.quotes.single() + + assertEquals(0, rendered.post.images.size) + assertEquals("https://64.media.tumblr.com/original.jpg", assertIs(quote.images.single()).url) + } + + @Test + fun pureReblogDoesNotMapTrailPostAsQuote() { + val post = + TumblrPost( + idString = "reblog-2", + blogName = "me", + blog = TumblrBlog(name = "me", title = "My Blog"), + content = emptyList(), + trail = + listOf( + TumblrTrailItem( + blog = TumblrBlog(name = "original", title = "Original Blog"), + post = TumblrTrailPost(id = "original-2"), + content = listOf(textBlock("Original text")), + ), + ), + ) + + val rendered = assertIs(post.toUiTimeline(accountKey)) + val message = assertNotNull(rendered.presentation.message) + val repost = assertNotNull(rendered.presentation.repost) + + assertEquals(0, rendered.presentation.quotes.size) + assertEquals(UiTimelineV2.Message.Type.Localized.MessageId.Repost, assertIs(message.type).data) + assertEquals(tumblrPostKey("me", "reblog-2"), message.statusKey) + assertEquals(tumblrPostKey("original", "original-2"), repost.statusKey) + assertEquals("Original text", repost.content.raw) + assertEquals(1, rendered.post.references.size) + } + + @Test + fun pureReblogWithTagsMovesTagsToQuote() { + val post = + TumblrPost( + idString = "reblog-tags-only", + blogName = "me", + blog = TumblrBlog(name = "me", title = "My Blog"), + content = emptyList(), + tags = listOf("Quoted Tag", "#NPF"), + trail = + listOf( + TumblrTrailItem( + blog = TumblrBlog(name = "original", title = "Original Blog"), + post = TumblrTrailPost(id = "original-tags-only"), + content = listOf(textBlock("Original text")), + ), + ), + ) + + val rendered = assertIs(post.toUiTimeline(accountKey)) + val quote = rendered.presentation.quotes.single() + + assertEquals("", rendered.post.content.raw) + assertEquals("Original text\n#Quoted Tag #NPF", quote.content.raw) + } + + @Test + fun pureImageReblogDoesNotMapTrailPostAsQuote() { + val post = + TumblrPost( + idString = "reblog-image", + blogName = "me", + blog = TumblrBlog(name = "me", title = "My Blog"), + content = + listOf( + imageBlock( + url = "https://64.media.tumblr.com/original.jpg", + width = 640, + height = 480, + altText = "original alt", + ), + ), + trail = + listOf( + TumblrTrailItem( + blog = TumblrBlog(name = "original", title = "Original Blog"), + post = TumblrTrailPost(id = "original-image"), + content = + listOf( + imageBlock( + url = "https://64.media.tumblr.com/original.jpg", + width = 640, + height = 480, + altText = "original alt", + ), + ), + ), + ), + ) + + val rendered = assertIs(post.toUiTimeline(accountKey)) + val repost = assertNotNull(rendered.presentation.repost) + + assertEquals(0, rendered.presentation.quotes.size) + assertEquals("https://64.media.tumblr.com/original.jpg", assertIs(repost.images.single()).url) + } + + private fun textBlock( + text: String, + formatting: List = emptyList(), + subtype: String? = null, + ) = TumblrNpfBlock( + type = "text", + subtype = subtype, + text = text, + formatting = formatting, + ) + + private fun imageBlock( + url: String, + width: Int, + height: Int, + altText: String, + additionalMedia: List = emptyList(), + ) = TumblrNpfBlock( + type = "image", + altText = altText, + media = + (listOf(TestImageMedia(url, width, height)) + additionalMedia).map { media -> + TumblrNpfMedia( + url = media.url, + width = media.width, + height = media.height, + ) + }, + ) + + private data class TestImageMedia( + val url: String, + val width: Int, + val height: Int, + ) + + private fun videoBlock( + url: String, + posterUrl: String, + width: Int, + height: Int, + title: String, + ) = TumblrNpfBlock( + type = "video", + title = title, + poster = listOf(TumblrNpfMedia(url = posterUrl)), + media = + listOf( + TumblrNpfMedia( + url = url, + type = "video/mp4", + width = width, + height = height, + ), + ), + ) + + private fun externalVideoBlock( + url: String, + posterUrl: String, + title: String, + ) = TumblrNpfBlock( + type = "video", + provider = "youtube", + url = url, + title = title, + poster = listOf(TumblrNpfMedia(url = posterUrl)), + media = + listOf( + TumblrNpfMedia( + url = url, + type = "text/html", + ), + ), + ) + + private fun linkBlock( + url: String, + title: String, + description: String, + posterUrl: String, + ) = TumblrNpfBlock( + type = "link", + url = url, + title = title, + description = description, + poster = listOf(TumblrNpfMedia(url = posterUrl)), + ) +} diff --git a/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/network/tumblr/TumblrServiceTest.kt b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/network/tumblr/TumblrServiceTest.kt new file mode 100644 index 0000000000..75513c45b7 --- /dev/null +++ b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/network/tumblr/TumblrServiceTest.kt @@ -0,0 +1,357 @@ +package dev.dimension.flare.data.network.tumblr + +import de.jensklingenberg.ktorfit.converter.ResponseConverterFactory +import de.jensklingenberg.ktorfit.ktorfit +import dev.dimension.flare.common.JSON +import dev.dimension.flare.data.network.nullableFallbackJson +import dev.dimension.flare.data.platform.TumblrCredential +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockRequestHandleScope +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.HttpRequestData +import io.ktor.client.request.HttpResponseData +import io.ktor.http.ContentType +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class TumblrServiceTest { + @Test + fun createPostUsesNpfPostsEndpointForJsonBody() = + runTest { + val requests = mutableListOf() + val service = + TumblrService( + credentialFlow = MutableStateFlow(credential()), + authResources = unusedAuthResources, + resources = + mockResources { scope, request -> + requests += request + scope.ok() + }, + ) + + service.createPost( + blogIdentifier = "mtlaster", + request = + TumblrCreatePostRequest( + content = + listOf( + TumblrNpfBlock( + type = "text", + text = "tst", + ), + ), + state = "private", + ), + media = emptyList(), + ) + + val request = requests.single() + assertEquals(HttpMethod.Post, request.method) + assertEquals("/v2/blog/mtlaster/posts", request.url.encodedPath) + assertEquals(ContentType.Application.Json, request.body.contentType) + } + + @Test + fun createPostUsesNpfPostsEndpointForMultipartBody() = + runTest { + val requests = mutableListOf() + val service = + TumblrService( + credentialFlow = MutableStateFlow(credential()), + authResources = unusedAuthResources, + resources = + mockResources { scope, request -> + requests += request + scope.ok() + }, + ) + + service.createPost( + blogIdentifier = "mtlaster", + request = + TumblrCreatePostRequest( + content = + listOf( + TumblrNpfBlock( + type = "image", + media = listOf(TumblrNpfMedia(identifier = "media0", type = "image/jpeg")), + ), + ), + state = "published", + ), + media = + listOf( + TumblrNpfMedia(identifier = "media0", type = "image/jpeg") to + ComposeMediaFile( + bytes = byteArrayOf(1, 2, 3), + fileName = "image.jpg", + mimeType = "image/jpeg", + ), + ), + ) + + val request = requests.single() + assertEquals(HttpMethod.Post, request.method) + assertEquals("/v2/blog/mtlaster/posts", request.url.encodedPath) + assertNotNull(request.body.contentType) + assertTrue(request.body.contentType?.match(ContentType.MultiPart.FormData) == true) + } + + @Test + fun createPostSerializesImageAndVideoMediaShape() { + val image = + JSON.encodeToString( + TumblrCreatePostRequest( + content = + listOf( + TumblrNpfBlock( + type = "image", + media = listOf(TumblrNpfMedia(identifier = "image0", type = "image/jpeg")), + ), + ), + ), + ) + val video = + JSON.encodeToString( + TumblrCreatePostRequest( + content = + listOf( + TumblrNpfBlock( + type = "video", + media = listOf(TumblrNpfMedia(identifier = "video0", type = "video/mp4")), + ), + ), + ), + ) + + assertTrue("\"media\":[{\"identifier\":\"image0\",\"type\":\"image/jpeg\"}]" in image) + assertTrue("\"media\":{\"identifier\":\"video0\",\"type\":\"video/mp4\"}" in video) + } + + @Test + fun timelineRequestsIncludeReblogInfo() = + runTest { + val requests = mutableListOf() + val service = + TumblrService( + credentialFlow = MutableStateFlow(credential()), + authResources = unusedAuthResources, + resources = + mockResources { scope, request -> + requests += request + scope.respondJson("""{"meta":{"status":200,"msg":"OK"},"response":{"posts":[]}}""") + }, + ) + + service.dashboard(limit = 20, offset = 40) + service.blogPosts(blogIdentifier = "mtlaster", limit = 20, offset = 40) + + assertEquals("true", requests[0].url.parameters["reblog_info"]) + assertEquals("true", requests[1].url.parameters["reblog_info"]) + } + + @Test + fun createPostDecodesNpfPostMutationResponse() = + runTest { + val resources = + mockResources { scope, _ -> + scope.respondJson( + """ + { + "meta": {"status": 201, "msg": "Created"}, + "response": { + "id": "123", + "id_string": "123", + "state": "private", + "display_text": "Posted to Tumblr" + } + } + """.trimIndent(), + status = HttpStatusCode.Created, + ) + } + + val envelope = + resources.createPost( + authorization = "Bearer token", + blogIdentifier = "mtlaster", + request = + TumblrCreatePostRequest( + content = + listOf( + TumblrNpfBlock( + type = "text", + text = "test", + ), + ), + state = "private", + ), + ) + + assertEquals("123", envelope.response?.id) + assertEquals("123", envelope.response?.idString) + assertEquals("private", envelope.response?.state) + assertEquals("Posted to Tumblr", envelope.response?.displayText) + } + + @Test + fun followDecodesBlogResponse() = + runTest { + val resources = + mockResources { scope, _ -> + scope.respondJson( + """ + { + "meta": {"status": 200, "msg": "OK"}, + "response": { + "blog": { + "name": "staff", + "title": "Tumblr Staff", + "url": "https://staff.tumblr.com/", + "uuid": "t:staff" + } + } + } + """.trimIndent(), + ) + } + + val envelope = + resources.follow( + authorization = "Bearer token", + blogUrl = "https://staff.tumblr.com/", + ) + + assertEquals("staff", envelope.response?.blog?.name) + assertEquals("Tumblr Staff", envelope.response?.blog?.title) + } + + @Test + fun actionResponseAllowsEmptyArrayBody() = + runTest { + val service = + TumblrService( + credentialFlow = MutableStateFlow(credential()), + authResources = unusedAuthResources, + resources = + mockResources { scope, _ -> + scope.respondJson("""{"meta":{"status":200,"msg":"OK"},"response":[]}""") + }, + ) + + service.like( + postId = "123", + reblogKey = "abc123", + ) + } + + @Test + fun reblogWithCommentUsesReblogEndpoint() = + runTest { + val requests = mutableListOf() + val service = + TumblrService( + credentialFlow = MutableStateFlow(credential()), + authResources = unusedAuthResources, + resources = + mockResources { scope, request -> + requests += request + scope.ok() + }, + ) + + service.reblog( + blogIdentifier = "mtlaster", + postId = "123", + reblogKey = "abc123", + comment = "quote text", + state = "private", + ) + + val request = requests.single() + assertEquals(HttpMethod.Post, request.method) + assertEquals("/v2/blog/mtlaster/post/reblog", request.url.encodedPath) + assertNotNull(request.body.contentType) + assertTrue(request.body.contentType?.match(ContentType.Application.FormUrlEncoded) == true) + } + + private fun credential(): TumblrCredential = + TumblrCredential( + accessToken = "token", + blogIdentifier = "mtlaster", + blogName = "mtlaster", + blogUrl = "https://mtlaster.tumblr.com/", + ) + + private fun mockResources(handler: TumblrRequestHandler): TumblrResources { + val client = + HttpClient( + MockEngine { request -> + handler.handle(this, request) + }, + ) { + install(ContentNegotiation) { + nullableFallbackJson(JSON) + } + } + return ktorfit { + baseUrl("https://api.tumblr.com/v2/") + httpClient(client) + converterFactories(ResponseConverterFactory()) + }.createTumblrResources() + } + + private val unusedAuthResources = + object : TumblrAuthResources { + override suspend fun requestToken( + grantType: String, + clientId: String, + clientSecret: String, + redirectUri: String, + code: String, + ): TumblrTokenResponse = error("Not used") + + override suspend fun refreshToken( + grantType: String, + clientId: String, + clientSecret: String, + refreshToken: String, + ): TumblrTokenResponse = error("Not used") + } + + private fun MockRequestHandleScope.ok() = + respondJson( + content = """{"meta":{"status":201,"msg":"Created"},"response":{}}""", + status = HttpStatusCode.Created, + ) + + private fun MockRequestHandleScope.respondJson( + content: String, + status: HttpStatusCode = HttpStatusCode.OK, + ) = respond( + content = content, + status = status, + headers = + Headers.build { + append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + }, + ) +} + +private fun interface TumblrRequestHandler { + suspend fun handle( + scope: MockRequestHandleScope, + request: HttpRequestData, + ): HttpResponseData +} From 3f1c020e28fdcca42f6752c9cf9da67cef837db6 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Mon, 13 Jul 2026 12:55:07 +0900 Subject: [PATCH 2/3] fix tumblr --- .../flare/ui/screen/compose/ComposeScreen.kt | 8 + app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values-zh-rTW/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../FlareAppleCore/FontAwesomeIcon.swift | 1 + .../FlareAppleCore/PresentationMappings.swift | 2 +- .../TimelineItem/StatusView.swift | 2 +- .../FontAwesome.xcassets/Contents.json | 2 +- .../tumblr.symbolset/Contents.json | 12 + .../tumblr.symbolset/tumblr.svg | 84 +++++ .../component/status/CommonStatusComponent.kt | 9 + .../values-zh-rCN/strings.xml | 2 + .../values-zh-rTW/strings.xml | 2 + .../main/composeResources/values/strings.xml | 2 + .../flare/ui/screen/compose/ComposeDialog.kt | 10 + .../feature/agent/common/ComposePostTool.kt | 6 +- .../feature/agent/common/AgentToolsTest.kt | 2 +- .../datasource/microblog/ComposeConfig.kt | 43 ++- .../dimension/flare/ui/model/UiTimelineV2.kt | 1 + .../ui/presenter/compose/ComposePresenter.kt | 39 ++- .../datasource/microblog/ComposeConfigTest.kt | 71 +++++ .../datasource/mastodon/MastodonDataSource.kt | 3 +- .../datasource/misskey/MisskeyDataSource.kt | 3 +- .../datasource/tumblr/TumblrDataSource.kt | 29 +- .../data/datasource/tumblr/TumblrKeys.kt | 42 ++- .../data/datasource/tumblr/TumblrLoader.kt | 2 +- .../data/datasource/tumblr/TumblrLoaders.kt | 16 +- .../data/datasource/tumblr/TumblrMapper.kt | 293 ++++++++++++------ .../flare/data/network/tumblr/TumblrModels.kt | 69 ++++- .../network/tumblr/TumblrPlatformDetector.kt | 11 +- .../data/network/tumblr/TumblrResources.kt | 3 +- .../data/network/tumblr/TumblrService.kt | 2 + .../flare/data/platform/TumblrPlatformSpec.kt | 52 ++++ .../ui/presenter/login/TumblrLoginProvider.kt | 32 +- .../datasource/tumblr/TumblrDataSourceTest.kt | 66 ++++ .../data/datasource/tumblr/TumblrKeysTest.kt | 12 + .../datasource/tumblr/TumblrLoadersTest.kt | 158 ++++++++++ .../datasource/tumblr/TumblrMapperTest.kt | 229 +++++++++++++- .../tumblr/TumblrPlatformDetectorTest.kt | 29 ++ .../data/network/tumblr/TumblrServiceTest.kt | 43 +++ .../data/platform/TumblrPlatformSpecTest.kt | 27 ++ .../login/TumblrLoginProviderTest.kt | 32 ++ 42 files changed, 1275 insertions(+), 182 deletions(-) create mode 100644 appleApp/Shared/FlareAppleResource/Resources/FontAwesome.xcassets/tumblr.symbolset/Contents.json create mode 100644 appleApp/Shared/FlareAppleResource/Resources/FontAwesome.xcassets/tumblr.symbolset/tumblr.svg create mode 100644 shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeConfigTest.kt create mode 100644 social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrLoadersTest.kt create mode 100644 social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/network/tumblr/TumblrPlatformDetectorTest.kt create mode 100644 social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/platform/TumblrPlatformSpecTest.kt create mode 100644 social/tumblr/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/login/TumblrLoginProviderTest.kt diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt index 1a5409ca7a..a5370bb69d 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt @@ -1559,6 +1559,10 @@ internal val UiTimelineV2.Post.Visibility.localName: Int R.string.misskey_visibility_specified } + UiTimelineV2.Post.Visibility.Private -> { + R.string.tumblr_visibility_private + } + UiTimelineV2.Post.Visibility.Channel -> { R.string.misskey_visibility_public } @@ -1583,6 +1587,10 @@ internal val UiTimelineV2.Post.Visibility.localDescription: Int R.string.misskey_visibility_specified_description } + UiTimelineV2.Post.Visibility.Private -> { + R.string.tumblr_visibility_private_description + } + UiTimelineV2.Post.Visibility.Channel -> { R.string.misskey_visibility_public_description } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index a8febf09b6..1835159461 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -212,6 +212,8 @@ 仅此实例上的用户可以看到此动态 仅关注者可以看到此动态 仅提及的用户可以看到此动态 + 私密 + 不公开显示 用户名 密码 2FA 验证码 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 6a120097ba..e6043d112c 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -175,6 +175,8 @@ 只有此站台上的使用者可以看到此貼文 只有追蹤者可以看到此貼文 只有被提及的使用者可以看到此貼文 + 私人 + 不公開顯示 使用者名稱 密碼 2FA 驗證碼 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 00c7d49695..7266da0474 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -241,6 +241,8 @@ Only users on this instance can see this post Only followers can see this post Only mentioned users can see this post + Private + Not publicly visible Username Password diff --git a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleCore/FontAwesomeIcon.swift b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleCore/FontAwesomeIcon.swift index 3b1b1c4c79..55275e7de4 100644 --- a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleCore/FontAwesomeIcon.swift +++ b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleCore/FontAwesomeIcon.swift @@ -89,6 +89,7 @@ public enum FontAwesomeIcon: String, CaseIterable, Hashable, Identifiable, Senda case tableList = "table-list" case telegram = "telegram" case thumbtack = "thumbtack" + case tumblr = "tumblr" case trash = "trash" case tv = "tv" case twitter = "twitter" diff --git a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleCore/PresentationMappings.swift b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleCore/PresentationMappings.swift index 0abbb475c3..2a60d05d14 100644 --- a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleCore/PresentationMappings.swift +++ b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleCore/PresentationMappings.swift @@ -282,7 +282,7 @@ public extension UiIcon { case .fanbox: .pixiv case .tumblr: - .squareRss + .tumblr case .list: .list case .feeds, .rss: diff --git a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/TimelineItem/StatusView.swift b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/TimelineItem/StatusView.swift index 586c423b93..8c6fa392b8 100644 --- a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/TimelineItem/StatusView.swift +++ b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/TimelineItem/StatusView.swift @@ -439,7 +439,7 @@ public struct StatusView: View { .font(.caption) .foregroundStyle(.secondary) case .tumblr: - Image(fontAwesome: .squareRss) + Image(fontAwesome: .tumblr) .font(.caption) .foregroundStyle(.secondary) } diff --git a/appleApp/Shared/FlareAppleResource/Resources/FontAwesome.xcassets/Contents.json b/appleApp/Shared/FlareAppleResource/Resources/FontAwesome.xcassets/Contents.json index 4aa7c5350b..73c00596a7 100644 --- a/appleApp/Shared/FlareAppleResource/Resources/FontAwesome.xcassets/Contents.json +++ b/appleApp/Shared/FlareAppleResource/Resources/FontAwesome.xcassets/Contents.json @@ -3,4 +3,4 @@ "author" : "xcode", "version" : 1 } -} \ No newline at end of file +} diff --git a/appleApp/Shared/FlareAppleResource/Resources/FontAwesome.xcassets/tumblr.symbolset/Contents.json b/appleApp/Shared/FlareAppleResource/Resources/FontAwesome.xcassets/tumblr.symbolset/Contents.json new file mode 100644 index 0000000000..de005f1df0 --- /dev/null +++ b/appleApp/Shared/FlareAppleResource/Resources/FontAwesome.xcassets/tumblr.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "tumblr.svg", + "idiom" : "universal" + } + ] +} diff --git a/appleApp/Shared/FlareAppleResource/Resources/FontAwesome.xcassets/tumblr.symbolset/tumblr.svg b/appleApp/Shared/FlareAppleResource/Resources/FontAwesome.xcassets/tumblr.symbolset/tumblr.svg new file mode 100644 index 0000000000..2fc4b68dd0 --- /dev/null +++ b/appleApp/Shared/FlareAppleResource/Resources/FontAwesome.xcassets/tumblr.symbolset/tumblr.svg @@ -0,0 +1,84 @@ + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.6.0 + Requires Xcode 16 or greater + Generated from square + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file 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 96c4644166..5142ed51ed 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 @@ -873,6 +873,15 @@ public fun StatusVisibilityComponent( ) } + UiTimelineV2.Post.Visibility.Private -> { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Lock, + contentDescription = stringResource(resource = Res.string.mastodon_visibility_private), + modifier = modifier, + tint = tint, + ) + } + UiTimelineV2.Post.Visibility.Specified -> { FAIcon( imageVector = FontAwesomeIcons.Solid.At, diff --git a/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml b/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml index ff09354b18..eb7f85c09b 100644 --- a/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml +++ b/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml @@ -398,6 +398,8 @@ 只有本实例的用户可以看到此动态 只有您的关注者可以看到此动态 只有被提及的用户可以看到此动态 + 私密 + 不公开显示 订阅管理 添加订阅 编辑订阅 diff --git a/desktopApp/src/main/composeResources/values-zh-rTW/strings.xml b/desktopApp/src/main/composeResources/values-zh-rTW/strings.xml index 171927a3c7..08ee70393c 100644 --- a/desktopApp/src/main/composeResources/values-zh-rTW/strings.xml +++ b/desktopApp/src/main/composeResources/values-zh-rTW/strings.xml @@ -344,6 +344,8 @@ 僅此執行體的使用者可以看到此貼文 僅您的跟隨者可以看到此貼文 僅被提及的使用者可以看到此貼文 + 私人 + 不公開顯示 訂閱管理 加入訂閱 編輯訂閱 diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index 08aff54c72..721d36a7df 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -441,6 +441,8 @@ Only users on this instance can see this post Only followers can see this post Only mentioned users can see this post + Private + Not publicly visible Subscriptions diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt index 26331223f4..4f9ee0e160 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt @@ -97,6 +97,8 @@ import dev.dimension.flare.misskey_visibility_specified_description import dev.dimension.flare.model.AccountType import dev.dimension.flare.navigate_back import dev.dimension.flare.ok +import dev.dimension.flare.tumblr_visibility_private +import dev.dimension.flare.tumblr_visibility_private_description import dev.dimension.flare.ui.component.AvatarComponent import dev.dimension.flare.ui.component.EmojiPicker import dev.dimension.flare.ui.component.FAIcon @@ -1488,6 +1490,10 @@ internal val UiTimelineV2.Post.Visibility.localName: StringResource Res.string.misskey_visibility_specified } + UiTimelineV2.Post.Visibility.Private -> { + Res.string.tumblr_visibility_private + } + UiTimelineV2.Post.Visibility.Channel -> { Res.string.misskey_visibility_public } @@ -1512,6 +1518,10 @@ internal val UiTimelineV2.Post.Visibility.localDescription: StringResource Res.string.misskey_visibility_specified_description } + UiTimelineV2.Post.Visibility.Private -> { + Res.string.tumblr_visibility_private_description + } + UiTimelineV2.Post.Visibility.Channel -> { Res.string.misskey_visibility_public_description } diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/ComposePostTool.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/ComposePostTool.kt index 9bb45a82e0..238c6c3cb4 100644 --- a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/ComposePostTool.kt +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/ComposePostTool.kt @@ -95,9 +95,13 @@ internal class ComposePostTool( return "Post content is ${content.length} characters, but ${target.platformType.name} allows " + "at most $maxLength for ${action.label}." } - if (visibility != UiTimelineV2.Post.Visibility.Public && config.visibility == null) { + val visibilityConfig = config.visibility + if (visibility != UiTimelineV2.Post.Visibility.Public && visibilityConfig == null) { return "${target.platformType.name} does not expose visibility selection for ${action.label} posts." } + if (visibilityConfig != null && visibility !in visibilityConfig.allVisibilities) { + return "${target.platformType.name} does not support ${visibility.name} visibility for ${action.label} posts." + } if (args.spoilerText.isNotBlank() && config.contentWarning == null) { return "${target.platformType.name} does not expose content warning selection for ${action.label} posts." } diff --git a/feature/agent/src/commonTest/kotlin/dev/dimension/flare/feature/agent/common/AgentToolsTest.kt b/feature/agent/src/commonTest/kotlin/dev/dimension/flare/feature/agent/common/AgentToolsTest.kt index 9ea45d4a80..23558b1715 100644 --- a/feature/agent/src/commonTest/kotlin/dev/dimension/flare/feature/agent/common/AgentToolsTest.kt +++ b/feature/agent/src/commonTest/kotlin/dev/dimension/flare/feature/agent/common/AgentToolsTest.kt @@ -1586,7 +1586,7 @@ private class StubComposeDataSource( override fun composeConfig(type: ComposeType): ComposeConfig = ComposeConfig( text = ComposeConfig.Text(maxLength = 300), - visibility = ComposeConfig.Visibility, + visibility = ComposeConfig.Visibility(), language = ComposeConfig.Language(maxCount = 1), ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeConfig.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeConfig.kt index eab8c4ae45..c082daa2ed 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeConfig.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeConfig.kt @@ -4,8 +4,11 @@ import androidx.compose.runtime.Immutable import dev.dimension.flare.common.CacheData import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiEmoji +import dev.dimension.flare.ui.model.UiTimelineV2 import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList @Immutable public data class ComposeConfig public constructor( @@ -294,7 +297,43 @@ public data class ComposeConfig public constructor( public data object ContentWarning @Immutable - public data object Visibility + public data class Visibility public constructor( + val allVisibilities: ImmutableList = + persistentListOf( + UiTimelineV2.Post.Visibility.Public, + UiTimelineV2.Post.Visibility.Home, + UiTimelineV2.Post.Visibility.Followers, + UiTimelineV2.Post.Visibility.Specified, + ), + val defaultVisibility: UiTimelineV2.Post.Visibility = UiTimelineV2.Post.Visibility.Public, + ) { + init { + require(allVisibilities.isNotEmpty()) { "Compose visibility options must not be empty" } + require(defaultVisibility in allVisibilities) { + "Default compose visibility must be included in the available options" + } + } + + internal fun merge(other: Visibility): Visibility? { + val commonVisibilities = + allVisibilities + .filter { it in other.allVisibilities } + .toImmutableList() + if (commonVisibilities.isEmpty()) { + return null + } + val mergedDefault = + defaultVisibility + .takeIf { it == other.defaultVisibility && it in commonVisibilities } + ?: UiTimelineV2.Post.Visibility.Public + .takeIf { it in commonVisibilities } + ?: commonVisibilities.first() + return Visibility( + allVisibilities = commonVisibilities, + defaultVisibility = mergedDefault, + ) + } + } internal fun merge(other: ComposeConfig): ComposeConfig { val text = @@ -329,7 +368,7 @@ public data class ComposeConfig public constructor( } val visibility = if (visibility != null && other.visibility != null) { - visibility + visibility.merge(other.visibility) } else { null } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimelineV2.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimelineV2.kt index 1324e09c81..3d1be8212a 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimelineV2.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimelineV2.kt @@ -361,6 +361,7 @@ public sealed class UiTimelineV2 { Home, Followers, Specified, + Private, Channel, } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt index 709a32862f..081acc2d80 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt @@ -493,8 +493,8 @@ public class ComposePresenter( composeConfig .mapNotNull { it.visibility - }.map { - visibilityPresenter() + }.map { visibilityConfig -> + visibilityPresenter(visibilityConfig) } val textMaxLength = composeConfig @@ -797,33 +797,32 @@ public class ComposePresenter( } @Composable - private fun visibilityPresenter(): VisibilityState { + private fun visibilityPresenter(config: ComposeConfig.Visibility): VisibilityState { var showVisibilityMenu by remember { mutableStateOf(false) } var visibility by remember { - mutableStateOf(UiTimelineV2.Post.Visibility.Public) + mutableStateOf(config.defaultVisibility) } var hasExplicitVisibility by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - appDataStore.composeConfigData.data.firstOrNull()?.let { data -> - if (!hasExplicitVisibility) { - visibility = data.visibility + LaunchedEffect(config) { + val candidate = + if (hasExplicitVisibility) { + visibility + } else { + appDataStore.composeConfigData.data + .firstOrNull() + ?.visibility } - } + visibility = candidate?.takeIf { it in config.allVisibilities } ?: config.defaultVisibility } + val resolvedVisibility = visibility.takeIf { it in config.allVisibilities } ?: config.defaultVisibility return object : VisibilityState { - override val visibility = visibility - - override val allVisibilities = - persistentListOf( - UiTimelineV2.Post.Visibility.Public, - UiTimelineV2.Post.Visibility.Home, - UiTimelineV2.Post.Visibility.Followers, - UiTimelineV2.Post.Visibility.Specified, - ) + override val visibility = resolvedVisibility + + override val allVisibilities = config.allVisibilities override val showVisibilityMenu: Boolean get() = showVisibilityMenu @@ -838,11 +837,11 @@ public class ComposePresenter( override fun setVisibility(value: UiTimelineV2.Post.Visibility) { hasExplicitVisibility = true - visibility = value + visibility = value.takeIf { it in config.allVisibilities } ?: config.defaultVisibility } override fun clear() { - visibility = UiTimelineV2.Post.Visibility.Public + visibility = config.defaultVisibility showVisibilityMenu = false hasExplicitVisibility = true } diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeConfigTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeConfigTest.kt new file mode 100644 index 0000000000..dd87bd145c --- /dev/null +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeConfigTest.kt @@ -0,0 +1,71 @@ +package dev.dimension.flare.data.datasource.microblog + +import dev.dimension.flare.ui.model.UiTimelineV2 +import kotlinx.collections.immutable.persistentListOf +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull + +class ComposeConfigTest { + @Test + fun defaultVisibilityOptionsDoNotExposePlatformSpecificPrivatePosts() { + assertFalse(UiTimelineV2.Post.Visibility.Private in ComposeConfig.Visibility().allVisibilities) + } + + @Test + fun visibilityMergeKeepsOnlyCommonOptions() { + val first = + visibility( + UiTimelineV2.Post.Visibility.Home, + UiTimelineV2.Post.Visibility.Public, + UiTimelineV2.Post.Visibility.Followers, + default = UiTimelineV2.Post.Visibility.Home, + ) + val second = + visibility( + UiTimelineV2.Post.Visibility.Public, + UiTimelineV2.Post.Visibility.Followers, + default = UiTimelineV2.Post.Visibility.Followers, + ) + + val merged = first.merge(second) + + assertEquals( + persistentListOf( + UiTimelineV2.Post.Visibility.Public, + UiTimelineV2.Post.Visibility.Followers, + ), + merged?.allVisibilities, + ) + assertEquals(UiTimelineV2.Post.Visibility.Public, merged?.defaultVisibility) + } + + @Test + fun visibilityMergeReturnsNullWhenThereIsNoCommonOption() { + val first = visibility(UiTimelineV2.Post.Visibility.Home) + val second = visibility(UiTimelineV2.Post.Visibility.Followers) + + assertNull(first.merge(second)) + } + + @Test + fun visibilityRequiresDefaultToBeAvailable() { + assertFailsWith { + ComposeConfig.Visibility( + allVisibilities = persistentListOf(UiTimelineV2.Post.Visibility.Public), + defaultVisibility = UiTimelineV2.Post.Visibility.Home, + ) + } + } + + private fun visibility( + vararg values: UiTimelineV2.Post.Visibility, + default: UiTimelineV2.Post.Visibility = values.first(), + ): ComposeConfig.Visibility = + ComposeConfig.Visibility( + allVisibilities = persistentListOf(*values), + defaultVisibility = default, + ) +} diff --git a/social/mastodon/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt b/social/mastodon/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt index 42f4936afd..2e00e3c112 100644 --- a/social/mastodon/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt +++ b/social/mastodon/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt @@ -313,6 +313,7 @@ internal open class MastodonDataSource( UiTimelineV2.Post.Visibility.Home -> Visibility.Unlisted UiTimelineV2.Post.Visibility.Followers -> Visibility.Private UiTimelineV2.Post.Visibility.Specified -> Visibility.Direct + UiTimelineV2.Post.Visibility.Private -> error("Mastodon does not support private compose visibility") UiTimelineV2.Post.Visibility.Channel -> Visibility.Public }, inReplyToID = inReplyToID, @@ -458,7 +459,7 @@ internal open class MastodonDataSource( accountKey = accountKey, ), contentWarning = ComposeConfig.ContentWarning, - visibility = ComposeConfig.Visibility, + visibility = ComposeConfig.Visibility(), language = ComposeConfig.Language(1), ) diff --git a/social/misskey/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt b/social/misskey/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt index d9fc50d7a2..ea7d9af44a 100644 --- a/social/misskey/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt +++ b/social/misskey/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt @@ -478,6 +478,7 @@ internal class MisskeyDataSource( UiTimelineV2.Post.Visibility.Home -> "home" UiTimelineV2.Post.Visibility.Followers -> "followers" UiTimelineV2.Post.Visibility.Specified -> "specified" + UiTimelineV2.Post.Visibility.Private -> error("Misskey does not support private compose visibility") UiTimelineV2.Post.Visibility.Channel -> "public" }, renoteId = renoteId, @@ -585,7 +586,7 @@ internal class MisskeyDataSource( accountKey = accountKey, ), contentWarning = ComposeConfig.ContentWarning, - visibility = ComposeConfig.Visibility, + visibility = ComposeConfig.Visibility(), ) fun favouriteState(statusKey: MicroBlogKey): Flow = diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrDataSource.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrDataSource.kt index f00a90319b..8a1c45451d 100644 --- a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrDataSource.kt +++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrDataSource.kt @@ -179,6 +179,7 @@ internal class TumblrDataSource( TumblrFollowingLoader( service = service, accountKey = accountKey, + blogKey = userKey, ) override fun fans(userKey: MicroBlogKey): RemoteLoader = @@ -208,7 +209,10 @@ internal class TumblrDataSource( require(data.poll == null) { "Tumblr poll compose is not supported" } val credential = credentialFlow.first() val referenceStatus = data.referenceStatus?.composeStatus - if (referenceStatus is ComposeStatus.Quote || referenceStatus is ComposeStatus.Reply) { + require(referenceStatus !is ComposeStatus.Reply) { + "Tumblr replies are not supported by the public API" + } + if (referenceStatus is ComposeStatus.Quote) { require(data.medias.isEmpty()) { "Tumblr reblog compose media is not supported" } val comment = data.content.trim() require(comment.isNotEmpty()) { "Tumblr reblog comment is empty" } @@ -280,7 +284,15 @@ internal class TumblrDataSource( altTextMaxLength = TUMBLR_TEXT_BLOCK_MAX_LENGTH, allowMediaOnly = true, ).takeUnless { type == ComposeType.Quote || type == ComposeType.Reply }, - visibility = ComposeConfig.Visibility, + visibility = + ComposeConfig.Visibility( + allVisibilities = + persistentListOf( + UiTimelineV2.Post.Visibility.Public, + UiTimelineV2.Post.Visibility.Private, + ), + defaultVisibility = UiTimelineV2.Post.Visibility.Public, + ), ) override suspend fun handle( @@ -360,14 +372,9 @@ private fun String?.toNpfBlockType(): String = else -> "image" } -private fun UiTimelineV2.Post.Visibility.toTumblrState(): String = +internal fun UiTimelineV2.Post.Visibility.toTumblrState(): String = when (this) { - UiTimelineV2.Post.Visibility.Public, - UiTimelineV2.Post.Visibility.Home, - -> "published" - - UiTimelineV2.Post.Visibility.Followers, - UiTimelineV2.Post.Visibility.Specified, - UiTimelineV2.Post.Visibility.Channel, - -> "private" + UiTimelineV2.Post.Visibility.Public -> "published" + UiTimelineV2.Post.Visibility.Private -> "private" + else -> error("Tumblr does not support $this post visibility") } diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrKeys.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrKeys.kt index 02c1f99d9c..380f70ec1b 100644 --- a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrKeys.kt +++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrKeys.kt @@ -33,16 +33,38 @@ internal fun MicroBlogKey.toTumblrPostKeyParts(): TumblrPostKeyParts { internal fun MicroBlogKey.toTumblrBlogIdentifier(): String = id.normalizedTumblrBlogName() -internal fun String.normalizedTumblrBlogName(): String = - trim() - .removePrefix("@") - .removePrefix("https://") - .removePrefix("http://") - .removePrefix("www.") - .removeSuffix("/") - .substringBefore(".tumblr.com") - .substringBefore("/") - .lowercase() +internal fun String.normalizedTumblrBlogName(): String { + val value = + trim() + .lowercase() + .removePrefix("https://") + .removePrefix("http://") + .substringBefore('?') + .substringBefore('#') + .removeSuffix("/") + val host = value.substringBefore('/') + val path = value.substringAfter('/', missingDelimiterValue = "") + + return when { + host == TUMBLR_HOST || host == "www.$TUMBLR_HOST" -> { + path.substringBefore('/').removePrefix("@") + } + + host.endsWith(".$TUMBLR_HOST") -> { + host + .removeSuffix(".$TUMBLR_HOST") + .removePrefix("www.") + } + + host.contains('.') -> { + host + } + + else -> { + host.removePrefix("@") + } + } +} internal fun tumblrBlogUrl(blogName: String): String = "https://${blogName.normalizedTumblrBlogName()}.tumblr.com/" diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrLoader.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrLoader.kt index f27b7da1d9..fdfae4f969 100644 --- a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrLoader.kt +++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrLoader.kt @@ -39,7 +39,7 @@ internal class TumblrLoader( override suspend fun relation(userKey: MicroBlogKey): UiRelation { val blog = service.blogInfo(userKey.toTumblrBlogIdentifier()) - return UiRelation(following = blog.following == true) + return UiRelation(following = blog.followed == true) } override suspend fun follow(userKey: MicroBlogKey) { diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrLoaders.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrLoaders.kt index 25219c22f1..643b0dfd5a 100644 --- a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrLoaders.kt +++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrLoaders.kt @@ -45,7 +45,7 @@ internal class TumblrBlogTimelineLoader( private val blogKey: MicroBlogKey, private val mediaOnly: Boolean = false, ) : CacheableRemoteLoader { - override val pagingKey: String = "tumblr_blog_${blogKey.id}_$accountKey" + override val pagingKey: String = "tumblr_blog_${blogKey.id}_${if (mediaOnly) "media" else "posts"}_$accountKey" override suspend fun load( pageSize: Int, @@ -71,6 +71,7 @@ internal class TumblrBlogTimelineLoader( accountKey = accountKey, nextOffset = (offset ?: 0) + response.posts.size, pageSize = pageSize, + paginationItemCount = response.posts.size, ) } } @@ -165,8 +166,9 @@ internal class TumblrBlogProfileLoader( internal class TumblrFollowingLoader( private val service: TumblrService, private val accountKey: MicroBlogKey, + private val blogKey: MicroBlogKey, ) : CacheableRemoteLoader { - override val pagingKey: String = "tumblr_following_$accountKey" + override val pagingKey: String = "tumblr_following_${blogKey.id}_$accountKey" override suspend fun load( pageSize: Int, @@ -176,7 +178,12 @@ internal class TumblrFollowingLoader( return PagingResult(endOfPaginationReached = true) } val offset = (request as? PagingRequest.Append)?.nextKey?.toIntOrNull() - val response = service.following(pageSize.coercePageSize(), offset) + val response = + service.following( + blogIdentifier = blogKey.toTumblrBlogIdentifier(), + limit = pageSize.coercePageSize(), + offset = offset, + ) return response.blogs.toProfileResult(accountKey, offset, pageSize) } } @@ -210,8 +217,9 @@ private fun List.toTimelineResult( accountKey: MicroBlogKey, nextOffset: Int, pageSize: Int, + paginationItemCount: Int = size, ): PagingResult { - val nextKey = nextOffset.takeIf { size >= pageSize.coercePageSize() }?.toString() + val nextKey = nextOffset.takeIf { paginationItemCount >= pageSize.coercePageSize() }?.toString() return PagingResult( data = map { it.toUiTimeline(accountKey) }, nextKey = nextKey, diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrMapper.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrMapper.kt index ef4ae8b515..549cac312a 100644 --- a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrMapper.kt +++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrMapper.kt @@ -7,10 +7,10 @@ import dev.dimension.flare.data.datasource.microblog.userActionsMenu import dev.dimension.flare.data.network.tumblr.TumblrBlog import dev.dimension.flare.data.network.tumblr.TumblrLegacyPhoto import dev.dimension.flare.data.network.tumblr.TumblrLegacyPhotoSize -import dev.dimension.flare.data.network.tumblr.TumblrNote import dev.dimension.flare.data.network.tumblr.TumblrNpfBlock import dev.dimension.flare.data.network.tumblr.TumblrNpfFormatting import dev.dimension.flare.data.network.tumblr.TumblrNpfFormattingBlog +import dev.dimension.flare.data.network.tumblr.TumblrNpfLayout import dev.dimension.flare.data.network.tumblr.TumblrNpfMedia import dev.dimension.flare.data.network.tumblr.TumblrPost import dev.dimension.flare.data.network.tumblr.TumblrTrailItem @@ -34,6 +34,7 @@ import dev.dimension.flare.ui.render.RenderRun import dev.dimension.flare.ui.render.RenderTextStyle import dev.dimension.flare.ui.render.UiDateTime import dev.dimension.flare.ui.render.UiRichText +import dev.dimension.flare.ui.render.parseHtml import dev.dimension.flare.ui.render.toUi import dev.dimension.flare.ui.render.toUiPlainText import dev.dimension.flare.ui.render.uiRichTextOf @@ -46,20 +47,21 @@ import kotlin.time.Instant internal fun TumblrPost.toUiTimeline(accountKey: MicroBlogKey): UiTimelineV2 { val postBlogName = resolvedBlogName() val statusKey = tumblrPostKey(postBlogName, resolvedId()) - val baseText = content.collectText().ifBlank { fallbackTextParts().joinToString(separator = "\n") }.trim() + val baseText = content.collectText(layout).ifBlank { fallbackTextParts().joinToString(separator = "\n") }.trim() val shouldMoveRootTagsToQuote = baseText.isBlank() && tags.isNotEmpty() && trail.isNotEmpty() val renderedContent = renderContent(tags = if (shouldMoveRootTagsToQuote) emptyList() else tags) val ownText = renderedContent.text val createdAt = createdAtInstant().toUi() val hasReblogComment = baseText.isNotBlank() || shouldMoveRootTagsToQuote - val referencedTrailPost = - collectReferencedTrailPost( + val referencedTrailPosts = + collectReferencedTrailPosts( accountKey = accountKey, + parentStatusKey = statusKey, fallbackCreatedAt = createdAt, fallbackTags = if (shouldMoveRootTagsToQuote) tags else emptyList(), ) - val quote = referencedTrailPost.takeIf { hasReblogComment } - val quoteMediaKeys = quote?.mediaDeduplicationKeys().orEmpty() + val quotes = referencedTrailPosts.takeIf { hasReblogComment }.orEmpty() + val quoteMediaKeys = quotes.flatMapTo(mutableSetOf()) { it.mediaDeduplicationKeys() } val postMedia = renderedContent.media.filterNot { it.deduplicationKey() in quoteMediaKeys } val actions = actionMenus(accountKey = accountKey, statusKey = statusKey).toPersistentList() val post = @@ -75,12 +77,17 @@ internal fun TumblrPost.toUiTimeline(accountKey: MicroBlogKey): UiTimelineV2 { statusKey = statusKey, card = renderedContent.card, createdAt = createdAt, - visibility = UiTimelineV2.Post.Visibility.Public, + visibility = + if (state == "private") { + UiTimelineV2.Post.Visibility.Private + } else { + UiTimelineV2.Post.Visibility.Public + }, references = trail .mapNotNull { trailItem -> val trailPostId = trailItem.post?.id ?: return@mapNotNull null - val trailBlogName = trailItem.blog?.name ?: postBlogName + val trailBlogName = trailItem.resolvedBlogName() ?: return@mapNotNull null UiTimelineV2.Post.Reference( statusKey = tumblrPostKey(trailBlogName, trailPostId), type = ReferenceType.Retweet, @@ -97,7 +104,8 @@ internal fun TumblrPost.toUiTimeline(accountKey: MicroBlogKey): UiTimelineV2 { itemKey = "tumblr_${statusKey.id}", ) val repost = - referencedTrailPost + referencedTrailPosts + .lastOrNull() ?.takeUnless { hasReblogComment } ?.copy(actions = actions) return UiTimelineV2.TimelinePostItem( @@ -112,14 +120,20 @@ internal fun TumblrPost.toUiTimeline(accountKey: MicroBlogKey): UiTimelineV2 { createdAt = createdAt, ) }, - quotes = listOfNotNull(quote).toPersistentList(), + inlineParents = + referencedTrailPosts + .dropLast(if (repost == null) 0 else 1) + .takeUnless { hasReblogComment } + .orEmpty() + .toPersistentList(), + quotes = quotes.toPersistentList(), repost = repost, ), ) } internal fun TumblrBlog.toUiProfile(accountKey: MicroBlogKey): UiProfile { - val blogName = name.normalizedTumblrBlogName() + val blogName = resolvedBlogName() ?: "unknown-tumblr-blog" val userKey = tumblrUserKey(blogName) return UiProfile( key = userKey, @@ -139,7 +153,7 @@ internal fun TumblrBlog.toUiProfile(accountKey: MicroBlogKey): UiProfile { ), ), banner = theme?.headerImage, - description = description?.takeIf { it.isNotBlank() }?.toUiPlainText(), + description = description?.takeIf { it.isNotBlank() }?.toTumblrDescriptionRichText(), matrices = UiProfile.Matrices( fansCount = followers ?: 0, @@ -151,6 +165,23 @@ internal fun TumblrBlog.toUiProfile(accountKey: MicroBlogKey): UiProfile { ) } +private fun TumblrBlog.resolvedBlogName(): String? = + name + ?.normalizedTumblrBlogName() + ?.takeIf { it.isNotBlank() } + ?: url + ?.normalizedTumblrBlogName() + ?.takeIf { it.isNotBlank() && it != "tumblr.com" } + ?: uuid?.takeIf { it.isNotBlank() }?.lowercase() + +private fun TumblrTrailItem.resolvedBlogName(): String? = + blog?.resolvedBlogName() + ?: brokenBlogName + ?.normalizedTumblrBlogName() + ?.takeIf { it.isNotBlank() } + +private fun String.toTumblrDescriptionRichText(): UiRichText = parseHtml(this).toUi() + private fun TumblrPost.repostMessage( accountKey: MicroBlogKey, statusKey: MicroBlogKey, @@ -180,21 +211,6 @@ private fun TumblrPost.actionMenus( statusKey: MicroBlogKey, ): List = buildList { - add( - ActionMenu.Item( - icon = UiIcon.Reply, - text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.Reply), - count = UiNumber(replyActionCount), - clickEvent = - ClickEvent.Deeplink( - DeeplinkRoute.Compose.Reply( - accountKey = accountKey, - statusKey = statusKey, - ), - ), - actionFamily = PostActionFamily.Reply, - ), - ) if (canReblog != false && reblogKey != null) { add( ActionMenu.Group( @@ -239,6 +255,17 @@ private fun TumblrPost.actionMenus( } val overflow = buildList { + noteCount?.let { totalNotes -> + add( + ActionMenu.Item( + icon = UiIcon.Comment, + text = ActionMenu.Item.Text.Raw("Notes"), + count = UiNumber(totalNotes), + clickEvent = ClickEvent.Noop, + actionFamily = PostActionFamily.Comment, + ), + ) + } val shareUrl = postUrl ?: shortUrl if (shareUrl != null) { add( @@ -332,43 +359,23 @@ private fun tumblrRepeatableRepostAction( actionFamily = PostActionFamily.Repost, ) -private val TumblrPost.replyActionCount: Long - get() = - replyCount - ?: repliesCount - ?: commentCount - ?: commentsCount - ?: notes.countTypes("reply", "replied", "comment", "commented") - private val TumblrPost.reblogActionCount: Long get() = reblogCount ?: reblogsCount - ?: notes.countTypes("reblog", "reblogged", "posted") + ?: 0 private val TumblrPost.likeActionCount: Long get() = likeCount ?: likesCount - ?: notes.countTypes("like", "liked") - -private fun List.countTypes(vararg types: String): Long { - val normalizedTypes = types.toSet() - return count { note -> - val type = - note.type - ?.trim() - ?.lowercase() - ?.replace('-', '_') - normalizedTypes.contains(type) - }.toLong() -} + ?: 0 internal fun TumblrPost.resolvedId(): String = idString ?: id?.toString() ?: error("Tumblr post id is missing") internal fun TumblrPost.resolvedBlogName(): String = blogName?.normalizedTumblrBlogName() - ?: blog?.name?.normalizedTumblrBlogName() + ?: blog?.resolvedBlogName() ?: error("Tumblr blog name is missing") private fun TumblrPost.createdAtInstant(): Instant = @@ -401,6 +408,7 @@ private data class TumblrNpfFormattingRange( private fun TumblrPost.renderContent(tags: List = this.tags): TumblrRenderedPostContent = renderContent( content = content, + layout = layout, tags = tags, fallbackTextParts = fallbackTextParts(), photos = photos, @@ -411,24 +419,32 @@ private fun TumblrPost.fallbackTextParts(): List = listOfNotNull(title, private fun TumblrTrailItem.renderContent(fallbackTags: List = emptyList()): TumblrRenderedPostContent = renderContent( content = content, + layout = layout, tags = tags.ifEmpty { post?.tags.orEmpty() }.ifEmpty { fallbackTags }, ) private fun renderContent( content: List, + layout: List, tags: List, fallbackTextParts: List = emptyList(), photos: List = emptyList(), ): TumblrRenderedPostContent { + val orderedContent = content.inLayoutDisplayOrder(layout) + val visibleContent = orderedContent.map { it.value } val tagLine = tags.toTumblrTagLine() val text = - content + visibleContent .collectText() .ifBlank { fallbackTextParts.joinToString(separator = "\n") }.appendTumblrTagLine(tagLine) - val shouldInlineImages = content.shouldInlineAllImages() - val renderedNpfContent = content.renderNpfContent(inlineImages = shouldInlineImages) + val shouldInlineImages = visibleContent.shouldInlineAllImages() + val renderedNpfContent = + orderedContent.renderNpfContent( + inlineImages = shouldInlineImages, + askLayout = layout.firstOrNull { it.type == "ask" }, + ) val fallbackTextRuns = fallbackTextParts.mapNotNull { part -> part.takeIf { it.isNotBlank() }?.toRenderTextContent() @@ -457,10 +473,30 @@ private fun renderContent( innerText = text, ), media = media, - card = content.collectCard(), + card = visibleContent.collectCard(), ) } +private fun List.inLayoutDisplayOrder(layout: List): List> { + val rowsLayout = layout.firstOrNull { it.type == "rows" } + val orderedIndexes = + rowsLayout + ?.let { rows -> + rows.display + .flatMap { it.blocks } + .ifEmpty { rows.rows.flatten() } + }.orEmpty() + .distinct() + val indexes = + if (rowsLayout != null && orderedIndexes.isNotEmpty()) { + // A rows layout also controls visibility (for example paywall blocks). + orderedIndexes + } else { + indices.toList() + } + return indexes.mapNotNull { index -> getOrNull(index)?.let { IndexedValue(index, it) } } +} + private fun String.appendTumblrTagLine(tagLine: String): String { val text = trim() return when { @@ -501,14 +537,17 @@ private fun TumblrNpfBlock.isImageBlockWithMedia(): Boolean = type == "image" && private fun TumblrNpfBlock.isInlineDecisionTextBlock(): Boolean = type == "text" && !text.isNullOrBlank() -private fun TumblrPost.collectReferencedTrailPost( +private fun TumblrPost.collectReferencedTrailPosts( accountKey: MicroBlogKey, + parentStatusKey: MicroBlogKey, fallbackCreatedAt: UiDateTime, fallbackTags: List, -): UiTimelineV2.Post? = - trail.firstNotNullOfOrNull { trailItem -> +): List = + trail.mapIndexedNotNull { index, trailItem -> trailItem.toReferencedPost( accountKey = accountKey, + parentStatusKey = parentStatusKey, + trailIndex = index, fallbackCreatedAt = fallbackCreatedAt, fallbackTags = fallbackTags, ) @@ -516,13 +555,15 @@ private fun TumblrPost.collectReferencedTrailPost( private fun TumblrTrailItem.toReferencedPost( accountKey: MicroBlogKey, + parentStatusKey: MicroBlogKey, + trailIndex: Int, fallbackCreatedAt: UiDateTime, fallbackTags: List, ): UiTimelineV2.Post? { - val trailPost = post ?: return null - val trailPostId = trailPost.id ?: return null - val trailBlog = blog ?: return null - val trailBlogName = trailBlog.name.normalizedTumblrBlogName() + val trailBlogName = resolvedBlogName() ?: return null + val trailBlog = blog ?: TumblrBlog(name = trailBlogName) + val resolvablePostId = post?.id + val trailPostId = resolvablePostId ?: "broken-${parentStatusKey.id.substringAfterLast(':')}-$trailIndex" val statusKey = tumblrPostKey(trailBlogName, trailPostId) val content = renderContent(fallbackTags = fallbackTags) @@ -536,42 +577,78 @@ private fun TumblrTrailItem.toReferencedPost( sensitive = false, contentWarning = null, user = trailBlog.toUiProfile(accountKey), - content = content.text.toUiPlainText(), + content = content.richText, actions = persistentListOf(), poll = null, statusKey = statusKey, card = content.card, - createdAt = fallbackCreatedAt, + createdAt = post?.timestamp?.let(Instant::fromEpochSeconds)?.toUi() ?: fallbackCreatedAt, visibility = UiTimelineV2.Post.Visibility.Public, references = persistentListOf(), clickEvent = - ClickEvent.Deeplink( - DeeplinkRoute.Status.Detail( - statusKey = statusKey, - accountType = AccountType.Specific(accountKey), - ), - ), + if (resolvablePostId == null) { + ClickEvent.Noop + } else { + ClickEvent.Deeplink( + DeeplinkRoute.Status.Detail( + statusKey = statusKey, + accountType = AccountType.Specific(accountKey), + ), + ) + }, accountType = AccountType.Specific(accountKey), itemKey = "tumblr_quote_${statusKey.id}", ) } -private fun List.renderNpfContent(inlineImages: Boolean): TumblrNpfRenderedContent { +private fun List>.renderNpfContent( + inlineImages: Boolean, + askLayout: TumblrNpfLayout?, +): TumblrNpfRenderedContent { val renderRuns = mutableListOf() val media = mutableListOf() - forEach { block -> - when (block.type) { - "image" -> { - val image = block.toBestImageMedia(block.altText) ?: return@forEach - if (inlineImages) { + val askBlockIndexes = askLayout?.blocks.orEmpty().toSet() + var askAttributionRendered = false + forEach { indexedBlock -> + val block = indexedBlock.value + val isAskBlock = indexedBlock.index in askBlockIndexes + if (isAskBlock && !askAttributionRendered) { + askAttributionRendered = true + askLayout + ?.attribution + ?.blog + ?.resolvedBlogName() + ?.let { blogName -> renderRuns += - RenderContent.BlockImage( - url = image.url, - href = block.url, + RenderContent.Text( + runs = persistentListOf(RenderRun.Text("@$blogName")), + block = RenderBlockStyle(isBlockQuote = true), ) - } else { - media += image } + } + when (block.type) { + "image" -> { + val image = block.toBestImageMedia(block.altText) + if (image != null) { + if (inlineImages) { + renderRuns += + RenderContent.BlockImage( + url = image.url, + href = block.url, + ) + } else { + media += image + } + } + block.caption + ?.takeIf { it.isNotBlank() } + ?.let { caption -> + renderRuns += + RenderContent.Text( + runs = persistentListOf(RenderRun.Text(caption)), + block = RenderBlockStyle(isFigCaption = true), + ) + } } "video" -> { @@ -594,7 +671,13 @@ private fun List.renderNpfContent(inlineImages: Boolean): Tumblr else -> { block .toRenderTextContent() - ?.let(renderRuns::add) + ?.let { content -> + if (isAskBlock) { + content.copy(block = content.block.copy(isBlockQuote = true)) + } else { + content + } + }?.let(renderRuns::add) } } } @@ -680,8 +763,8 @@ private fun String.toRenderTextRuns( private fun List.toRanges(text: String): List = mapNotNull { formatting -> val type = formatting.type?.lowercase() ?: return@mapNotNull null - val start = formatting.start?.let(text::safeCharIndex) ?: return@mapNotNull null - val end = formatting.end?.let(text::safeCharIndex) ?: return@mapNotNull null + val start = formatting.start?.let(text::charIndexForCodePointOffset) ?: return@mapNotNull null + val end = formatting.end?.let(text::charIndexForCodePointOffset) ?: return@mapNotNull null if (start >= end) return@mapNotNull null TumblrNpfFormattingRange( type = type, @@ -713,19 +796,6 @@ private fun List.toRenderTextStyle(baseStyle: RenderTe private fun TumblrNpfFormattingBlog.toTumblrBlogUrl(): String? = url ?: name?.normalizedTumblrBlogName()?.let { "https://www.tumblr.com/$it" } -private fun String.safeCharIndex(offset: Int): Int { - val direct = offset.coerceIn(0, length) - if (!isInsideSurrogatePair(direct)) { - return direct - } - return charIndexForCodePointOffset(offset) -} - -private fun String.isInsideSurrogatePair(index: Int): Boolean = - index in 1 until length && - this[index - 1].isHighSurrogateChar() && - this[index].isLowSurrogateChar() - private fun String.charIndexForCodePointOffset(offset: Int): Int { var charIndex = 0 var codePointIndex = 0 @@ -752,6 +822,11 @@ private fun List.collectText(): String = appendNpfText(this) }.trim() +private fun List.collectText(layout: List): String = + inLayoutDisplayOrder(layout) + .map { it.value } + .collectText() + private fun List.appendNpfText(builder: StringBuilder) { forEach { block -> when (block.type) { @@ -771,6 +846,14 @@ private fun List.appendNpfText(builder: StringBuilder) { } } + "image" -> { + val caption = block.caption + if (!caption.isNullOrBlank()) { + if (builder.isNotEmpty()) builder.append('\n') + builder.append(caption) + } + } + else -> { val text = block.text ?: block.title if (!text.isNullOrBlank()) { @@ -985,8 +1068,24 @@ private fun TumblrNpfBlock.posterUrl(): String? = ?: poster.firstNotNullOfOrNull { it.url } private fun TumblrNpfBlock.audioUrl(): String? = - url + media + .firstOrNull { item -> + val mediaUrl = item.url ?: return@firstOrNull false + item.type?.startsWith("audio/", ignoreCase = true) == true || mediaUrl.isLikelyPlayableAudioUrl() + }?.url ?: media.firstNotNullOfOrNull { it.url } + ?: url?.takeIf { it.isLikelyPlayableAudioUrl() } + +private fun String.isLikelyPlayableAudioUrl(): Boolean { + val path = substringBefore('?').substringBefore('#').lowercase() + return path.endsWith(".mp3") || + path.endsWith(".m4a") || + path.endsWith(".aac") || + path.endsWith(".ogg") || + path.endsWith(".oga") || + path.endsWith(".wav") || + path.endsWith(".flac") +} private fun String.isLikelyPlayableVideoUrl( type: String?, diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrModels.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrModels.kt index c46739f5b3..eeadecbdd8 100644 --- a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrModels.kt +++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrModels.kt @@ -130,7 +130,7 @@ internal data class TumblrPostMutationResponse( @Serializable internal data class TumblrBlog( - val name: String, + val name: String? = null, val title: String? = null, val description: String? = null, val url: String? = null, @@ -142,6 +142,7 @@ internal data class TumblrBlog( val totalPosts: Long? = null, val posts: Long? = null, val following: Boolean? = null, + val followed: Boolean? = null, val primary: Boolean? = null, val theme: TumblrBlogTheme? = null, ) @@ -160,6 +161,7 @@ internal data class TumblrPost( @SerialName("id_string") val idString: String? = null, val type: String? = null, + val state: String? = null, @SerialName("blog_name") val blogName: String? = null, @SerialName("blog") @@ -221,6 +223,8 @@ internal data class TumblrTrailItem( val content: List = emptyList(), val layout: List = emptyList(), val tags: List = emptyList(), + @SerialName("broken_blog_name") + val brokenBlogName: String? = null, @SerialName("post") val post: TumblrTrailPost? = null, ) @@ -228,6 +232,7 @@ internal data class TumblrTrailItem( @Serializable internal data class TumblrTrailPost( val id: String? = null, + val timestamp: Long? = null, val tags: List = emptyList(), ) @@ -284,9 +289,17 @@ internal data class TumblrNpfBlock( val text: String? = null, val formatting: List = emptyList(), val title: String? = null, + val caption: String? = null, val url: String? = null, val description: String? = null, + val author: String? = null, + @SerialName("site_name") + val siteName: String? = null, + val artist: String? = null, + val album: String? = null, val provider: String? = null, + @SerialName("embed_url") + val embedUrl: String? = null, val media: List = emptyList(), val poster: List = emptyList(), @SerialName("alt_text") @@ -295,6 +308,8 @@ internal data class TumblrNpfBlock( val thumbnailUrl: String? = null, val width: Int? = null, val height: Int? = null, + @SerialName("indent_level") + val indentLevel: Int? = null, @SerialName("embed_iframe") val embedIframe: TumblrNpfEmbedIframe? = null, val raw: JsonObject? = null, @@ -334,6 +349,32 @@ internal data class TumblrNpfEmbedIframe( @Serializable internal data class TumblrNpfLayout( val type: String? = null, + val display: List = emptyList(), + // Older trail payloads used `rows` directly instead of `display`. + val rows: List> = emptyList(), + val blocks: List = emptyList(), + @SerialName("truncate_after") + val truncateAfter: Int? = null, + val attribution: TumblrNpfAttribution? = null, +) + +@Serializable +internal data class TumblrNpfLayoutDisplay( + val blocks: List = emptyList(), + val mode: TumblrNpfLayoutMode? = null, +) + +@Serializable +internal data class TumblrNpfLayoutMode( + val type: String? = null, +) + +@Serializable +internal data class TumblrNpfAttribution( + val type: String? = null, + val url: String? = null, + val blog: TumblrBlog? = null, + val post: TumblrTrailPost? = null, ) internal object TumblrNpfBlockSerializer : KSerializer { @@ -355,15 +396,22 @@ internal object TumblrNpfBlockSerializer : KSerializer { }.getOrNull() }.orEmpty(), title = obj.stringOrNull("title"), + caption = obj.stringOrNull("caption"), url = obj.stringOrNull("url"), description = obj.stringOrNull("description"), + author = obj.stringOrNull("author"), + siteName = obj.stringOrNull("site_name"), + artist = obj.stringOrNull("artist"), + album = obj.stringOrNull("album"), provider = obj.stringOrNull("provider"), + embedUrl = obj.stringOrNull("embed_url"), media = obj.mediaList("media"), poster = obj.mediaList("poster"), altText = obj.stringOrNull("alt_text"), thumbnailUrl = obj.stringOrNull("thumbnail_url"), width = obj.intOrNull("width"), height = obj.intOrNull("height"), + indentLevel = obj.intOrNull("indent_level"), embedIframe = obj.objectOrNull("embed_iframe")?.let { TumblrNpfEmbedIframe(url = it.stringOrNull("url")) }, raw = obj, ) @@ -383,19 +431,26 @@ internal object TumblrNpfBlockSerializer : KSerializer { put("formatting", output.json.encodeToJsonElement(value.formatting)) } value.title?.let { put("title", it) } + value.caption?.let { put("caption", it) } value.url?.let { put("url", it) } value.description?.let { put("description", it) } + value.author?.let { put("author", it) } + value.siteName?.let { put("site_name", it) } + value.artist?.let { put("artist", it) } + value.album?.let { put("album", it) } value.provider?.let { put("provider", it) } + value.embedUrl?.let { put("embed_url", it) } if (value.media.isNotEmpty()) { - put("media", value.media.toNpfMediaJsonElement(blockType = value.type)) + put("media", value.media.toNpfMediaJsonElement(asArray = value.type != "video" && value.type != "audio")) } if (value.poster.isNotEmpty()) { - put("poster", value.poster.toNpfMediaJsonElement(blockType = value.type)) + put("poster", value.poster.toNpfMediaJsonElement(asArray = true)) } value.altText?.let { put("alt_text", it) } value.thumbnailUrl?.let { put("thumbnail_url", it) } value.width?.let { put("width", it) } value.height?.let { put("height", it) } + value.indentLevel?.let { put("indent_level", it) } value.embedIframe?.url?.let { url -> put( "embed_iframe", @@ -409,11 +464,11 @@ internal object TumblrNpfBlockSerializer : KSerializer { } } -private fun List.toNpfMediaJsonElement(blockType: String?) = - if (blockType == "video" || blockType == "audio") { - tumblrNpfMediaJsonObject(first()) - } else { +private fun List.toNpfMediaJsonElement(asArray: Boolean) = + if (asArray) { JsonArray(map { tumblrNpfMediaJsonObject(it) }) + } else { + tumblrNpfMediaJsonObject(first()) } private fun tumblrNpfMediaJsonObject(media: TumblrNpfMedia): JsonObject = diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrPlatformDetector.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrPlatformDetector.kt index 1283c5863f..91fdd9726a 100644 --- a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrPlatformDetector.kt +++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrPlatformDetector.kt @@ -10,7 +10,7 @@ internal data object TumblrPlatformDetector : PlatformDetector { override val priority: Int = 80 override suspend fun detect(host: String): NodeData? { - if (!TUMBLR_HOST.equals(host, ignoreCase = true) && !TUMBLR_WEB_HOST.equals(host, ignoreCase = true)) { + if (!host.isTumblrHost()) { return null } return NodeData( @@ -21,3 +21,12 @@ internal data object TumblrPlatformDetector : PlatformDetector { ) } } + +private fun String.isTumblrHost(): Boolean { + val normalized = lowercase() + if (normalized == TUMBLR_HOST || normalized == TUMBLR_WEB_HOST) { + return true + } + val blogName = normalized.removeSuffix(".$TUMBLR_HOST") + return blogName != normalized && blogName.isNotBlank() && !blogName.contains('.') +} diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrResources.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrResources.kt index 1bccfb23a9..f5c1498532 100644 --- a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrResources.kt +++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrResources.kt @@ -80,9 +80,10 @@ internal interface TumblrResources { @Path("blogIdentifier") blogIdentifier: String, ): TumblrEnvelope - @GET("user/following") + @GET("blog/{blogIdentifier}/following") suspend fun following( @Header("Authorization") authorization: String, + @Path("blogIdentifier") blogIdentifier: String, @Query("limit") limit: Int, @Query("offset") offset: Int? = null, ): TumblrEnvelope diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrService.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrService.kt index 25927208e1..127213ffb1 100644 --- a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrService.kt +++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrService.kt @@ -121,12 +121,14 @@ internal class TumblrService( .blog suspend fun following( + blogIdentifier: String, limit: Int, offset: Int?, ): TumblrBlogPage = resources .following( authorization = authorization(), + blogIdentifier = blogIdentifier, limit = limit.coercePageSize(), offset = offset, ).requiredResponse() diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/platform/TumblrPlatformSpec.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/platform/TumblrPlatformSpec.kt index b2e891b386..14378174e5 100644 --- a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/platform/TumblrPlatformSpec.kt +++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/platform/TumblrPlatformSpec.kt @@ -12,6 +12,7 @@ import dev.dimension.flare.model.PlatformDeepLink import dev.dimension.flare.model.PlatformSpec import dev.dimension.flare.model.PlatformType import dev.dimension.flare.model.PlatformTypeMetadata +import dev.dimension.flare.social.tumblr.TumblrBuildConfig import dev.dimension.flare.ui.model.UiIcon import dev.dimension.flare.ui.presenter.login.LoginPlatformProvider import dev.dimension.flare.ui.presenter.login.TumblrLoginProvider @@ -30,6 +31,9 @@ public data object TumblrPlatformSpec : PlatformSpec, LoginPlatformProvider by TumblrLoginProvider { override val type: PlatformType = PlatformType.Tumblr + public val isConfigured: Boolean + get() = TumblrBuildConfig.configured + override val metadata: PlatformTypeMetadata = PlatformTypeMetadata( displayName = "Tumblr", @@ -42,7 +46,9 @@ public data object TumblrPlatformSpec : override fun deepLinks(accountKey: MicroBlogKey): ImmutableList> = persistentListOf( tumblrWebPostDeepLink(accountKey, "https://www.tumblr.com/{blogName}/{id}"), + tumblrWebPostWithSlugDeepLink(accountKey, "https://www.tumblr.com/{blogName}/{id}/{slug}"), tumblrSubdomainPostDeepLink(accountKey, "https://{blogname}.tumblr.com/post/{id}"), + tumblrSubdomainPostWithSlugDeepLink(accountKey, "https://{blogname}.tumblr.com/post/{id}/{slug}"), tumblrWebProfileDeepLink(accountKey, "https://www.tumblr.com/{blogName}"), tumblrSubdomainProfileDeepLink(accountKey, "https://{blogname}.tumblr.com"), ) @@ -110,6 +116,37 @@ private fun tumblrSubdomainPostDeepLink( }, ) +private fun tumblrWebPostWithSlugDeepLink( + accountKey: MicroBlogKey, + uriPattern: String, +): PlatformDeepLink = + PlatformDeepLink( + uriPattern = uriPattern, + serializer = TumblrWebPostWithSlugDeepLink.serializer(), + callback = { data -> + DeeplinkRoute.Status.Detail( + accountType = AccountType.Specific(accountKey), + statusKey = tumblrPostKey(data.blogName, data.id), + ) + }, + ) + +private fun tumblrSubdomainPostWithSlugDeepLink( + accountKey: MicroBlogKey, + uriPattern: String, +): PlatformDeepLink = + PlatformDeepLink( + uriPattern = uriPattern, + serializer = TumblrSubdomainPostWithSlugDeepLink.serializer(), + matcher = { data -> !data.blogName.equals("www", ignoreCase = true) }, + callback = { data -> + DeeplinkRoute.Status.Detail( + accountType = AccountType.Specific(accountKey), + statusKey = tumblrPostKey(data.blogName, data.id), + ) + }, + ) + private fun tumblrWebProfileDeepLink( accountKey: MicroBlogKey, uriPattern: String, @@ -154,6 +191,21 @@ private data class TumblrSubdomainPostDeepLink( val id: String, ) +@Serializable +private data class TumblrWebPostWithSlugDeepLink( + val blogName: String, + val id: String, + val slug: String, +) + +@Serializable +private data class TumblrSubdomainPostWithSlugDeepLink( + @SerialName("blogname") + val blogName: String, + val id: String, + val slug: String, +) + @Serializable private data class TumblrWebProfileDeepLink( val blogName: String, diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/TumblrLoginProvider.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/TumblrLoginProvider.kt index 8fc6655f6d..33ea49ad3a 100644 --- a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/TumblrLoginProvider.kt +++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/TumblrLoginProvider.kt @@ -140,6 +140,9 @@ private class TumblrOAuthLoginHandler( _state.value = oauthState(loading = true) runCatching { val parsedUrl = Url(value) + parsedUrl.parameters["error"]?.let { oauthError -> + error(parsedUrl.parameters["error_description"] ?: oauthError) + } val code = parsedUrl.parameters["code"] ?: error("Missing Tumblr OAuth code") val state = parsedUrl.parameters["state"] ?: error("Missing Tumblr OAuth state") val pending = @@ -172,11 +175,19 @@ private class TumblrOAuthLoginHandler( ), ), ) - val blogs = userInfoService.userInfo().user.blogs - require(blogs.isNotEmpty()) { "Tumblr account has no blogs" } + val blogs = + userInfoService + .userInfo() + .user.blogs + .mapNotNull { blog -> + blog.name + ?.takeIf { it.isNotBlank() } + ?.let { blogName -> blog to blogName } + } + require(blogs.isNotEmpty()) { "Tumblr account has no named blogs" } val target = context.reloginTarget - blogs.forEach { blog -> - val accountKey = MicroBlogKey(id = blog.name, host = TUMBLR_HOST) + blogs.forEach { (blog, blogName) -> + val accountKey = MicroBlogKey(id = blogName, host = TUMBLR_HOST) if (target != null && target.accountKey != accountKey) { return@forEach } @@ -189,16 +200,18 @@ private class TumblrOAuthLoginHandler( ), credential = token.toCredential( - blogIdentifier = blog.name, - blogName = blog.name, - blogUrl = blog.url ?: "https://${blog.name}.tumblr.com/", + blogIdentifier = blogName, + blogName = blogName, + blogUrl = blog.url ?: "https://$blogName.tumblr.com/", blogUuid = blog.uuid, isPrimary = blog.primary == true, ), serializer = TumblrCredential.serializer(), ) } - if (target != null && blogs.none { MicroBlogKey(id = it.name, host = TUMBLR_HOST) == target.accountKey }) { + if (target != null && + blogs.none { (_, blogName) -> MicroBlogKey(id = blogName, host = TUMBLR_HOST) == target.accountKey } + ) { error("Relogin account not found in Tumblr blogs: ${target.accountKey}") } pendingRepository.clear(pending) @@ -213,7 +226,8 @@ private class TumblrOAuthLoginHandler( override fun canResume(value: String): Boolean = runCatching { val parsed = Url(value) - parsed.parameters["code"] != null && parsed.parameters["state"] != null + parsed.parameters["state"] != null && + (parsed.parameters["code"] != null || parsed.parameters["error"] != null) }.getOrDefault(false) override fun clear() { diff --git a/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrDataSourceTest.kt b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrDataSourceTest.kt index 4b652c470a..ebebf8b491 100644 --- a/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrDataSourceTest.kt +++ b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrDataSourceTest.kt @@ -1,13 +1,19 @@ package dev.dimension.flare.data.datasource.tumblr +import dev.dimension.flare.data.datasource.microblog.ComposeData +import dev.dimension.flare.data.datasource.microblog.ComposeType import dev.dimension.flare.data.model.appearance.AppearanceKeys import dev.dimension.flare.data.model.tab.ShortcutSpec import dev.dimension.flare.data.model.tab.TimelineCandidate import dev.dimension.flare.data.platform.TumblrCredential import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.presenter.compose.ComposeStatus import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -27,6 +33,66 @@ class TumblrDataSourceTest { assertFullWidthPost(assertIs(dataSource.shortcuts.single().target).candidate) } + @Test + fun composeExposesOnlySupportedVisibilityChoices() { + val dataSource = dataSource() + val visibility = assertNotNull(dataSource.composeConfig(ComposeType.New).visibility) + + assertEquals( + listOf( + UiTimelineV2.Post.Visibility.Public, + UiTimelineV2.Post.Visibility.Private, + ), + visibility.allVisibilities, + ) + assertEquals(UiTimelineV2.Post.Visibility.Public, visibility.defaultVisibility) + } + + @Test + fun composeVisibilityMapsToTumblrPostState() { + assertEquals( + "published", + UiTimelineV2.Post.Visibility.Public + .toTumblrState(), + ) + assertEquals( + "private", + UiTimelineV2.Post.Visibility.Private + .toTumblrState(), + ) + assertFailsWith { + UiTimelineV2.Post.Visibility.Home + .toTumblrState() + } + } + + @Test + fun replyComposeIsRejectedInsteadOfCreatingReblog() = + runTest { + val dataSource = dataSource() + + assertFailsWith { + dataSource.compose( + data = + ComposeData( + content = "This must not become a reblog", + referenceStatus = + ComposeData.ReferenceStatus( + ComposeStatus.Reply(tumblrPostKey("staff", "123")), + ), + ), + progress = {}, + ) + } + } + + private fun dataSource(): TumblrDataSource = + TumblrDataSource( + accountKey = accountKey, + credentialFlow = flowOf(credential()), + updateCredential = {}, + ) + private fun assertFullWidthPost(candidate: TimelineCandidate<*>) { val patch = assertNotNull(candidate.appearancePatch) diff --git a/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrKeysTest.kt b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrKeysTest.kt index 659485788c..934e8492a3 100644 --- a/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrKeysTest.kt +++ b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrKeysTest.kt @@ -16,6 +16,18 @@ class TumblrKeysTest { MicroBlogKey(id = "staff", host = TUMBLR_HOST), tumblrUserKey("@Staff"), ) + assertEquals( + MicroBlogKey(id = "staff", host = TUMBLR_HOST), + tumblrUserKey("https://www.tumblr.com/staff"), + ) + assertEquals( + MicroBlogKey(id = "staff", host = TUMBLR_HOST), + tumblrUserKey("https://www.tumblr.com/@Staff"), + ) + assertEquals( + MicroBlogKey(id = "www.davidslog.com", host = TUMBLR_HOST), + tumblrUserKey("https://www.davidslog.com/archive"), + ) } @Test diff --git a/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrLoadersTest.kt b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrLoadersTest.kt new file mode 100644 index 0000000000..7b9c7a3fe2 --- /dev/null +++ b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrLoadersTest.kt @@ -0,0 +1,158 @@ +package dev.dimension.flare.data.datasource.tumblr + +import de.jensklingenberg.ktorfit.converter.ResponseConverterFactory +import de.jensklingenberg.ktorfit.ktorfit +import dev.dimension.flare.common.JSON +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.network.nullableFallbackJson +import dev.dimension.flare.data.network.tumblr.TumblrAuthResources +import dev.dimension.flare.data.network.tumblr.TumblrResources +import dev.dimension.flare.data.network.tumblr.TumblrService +import dev.dimension.flare.data.network.tumblr.TumblrTokenResponse +import dev.dimension.flare.data.network.tumblr.createTumblrResources +import dev.dimension.flare.data.platform.TumblrCredential +import dev.dimension.flare.model.MicroBlogKey +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.ContentType +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class TumblrLoadersTest { + @Test + fun postsAndMediaTimelinesUseDifferentPagingKeys() { + val service = serviceReturning("""{"meta":{"status":200},"response":{"posts":[]}}""") + + val posts = TumblrBlogTimelineLoader(service, accountKey, blogKey, mediaOnly = false) + val media = TumblrBlogTimelineLoader(service, accountKey, blogKey, mediaOnly = true) + + assertNotEquals(posts.pagingKey, media.pagingKey) + } + + @Test + fun mediaTimelineKeepsNextPageWhenUnfilteredResponseIsFull() = + runTest { + val service = serviceReturning(fullPostsPageJson()) + val loader = TumblrBlogTimelineLoader(service, accountKey, blogKey, mediaOnly = true) + + val result = loader.load(pageSize = 20, request = PagingRequest.Refresh) + + assertEquals(1, result.data.size) + assertEquals("20", result.nextKey) + } + + @Test + fun followingPagingKeyIncludesRequestedBlog() { + val service = serviceReturning("""{"meta":{"status":200},"response":{"blogs":[]}}""") + val staff = TumblrFollowingLoader(service, accountKey, tumblrUserKey("staff")) + val engineering = TumblrFollowingLoader(service, accountKey, tumblrUserKey("engineering")) + + assertNotEquals(staff.pagingKey, engineering.pagingKey) + assertTrue(staff.pagingKey.contains("staff")) + } + + @Test + fun relationUsesFollowedField() = + runTest { + val service = + serviceReturning( + """ + { + "meta": {"status": 200, "msg": "OK"}, + "response": { + "blog": { + "name": "staff", + "following": false, + "followed": true + } + } + } + """.trimIndent(), + ) + val loader = TumblrLoader(service, accountKey) + + val relation = loader.relation(blogKey) + + assertTrue(relation.following) + assertFalse(relation.blocking) + } + + private fun serviceReturning(responseJson: String): TumblrService { + val client = + HttpClient( + MockEngine { + respond( + content = responseJson, + status = HttpStatusCode.OK, + headers = Headers.build { append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) }, + ) + }, + ) { + install(ContentNegotiation) { + nullableFallbackJson(JSON) + } + } + val resources = + ktorfit { + baseUrl("https://api.tumblr.com/v2/") + httpClient(client) + converterFactories(ResponseConverterFactory()) + }.createTumblrResources() + return TumblrService( + credentialFlow = MutableStateFlow(credential), + authResources = unusedAuthResources, + resources = resources, + ) + } + + private fun fullPostsPageJson(): String { + val posts = + (1..20).joinToString(separator = ",") { id -> + if (id == 1) { + """{"id_string":"1","blog_name":"staff","content":[{"type":"image","media":[{"url":"https://64.media.tumblr.com/1.jpg","width":800,"height":600}]}]}""" + } else { + """{"id_string":"$id","blog_name":"staff","content":[{"type":"text","text":"post $id"}]}""" + } + } + return """{"meta":{"status":200,"msg":"OK"},"response":{"posts":[$posts]}}""" + } + + private companion object { + val accountKey = MicroBlogKey(id = "me", host = "tumblr.com") + val blogKey = tumblrUserKey("staff") + val credential = + TumblrCredential( + accessToken = "token", + blogIdentifier = "me", + blogName = "me", + blogUrl = "https://me.tumblr.com/", + ) + val unusedAuthResources = + object : TumblrAuthResources { + override suspend fun requestToken( + grantType: String, + clientId: String, + clientSecret: String, + redirectUri: String, + code: String, + ): TumblrTokenResponse = error("Not used") + + override suspend fun refreshToken( + grantType: String, + clientId: String, + clientSecret: String, + refreshToken: String, + ): TumblrTokenResponse = error("Not used") + } + } +} diff --git a/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrMapperTest.kt b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrMapperTest.kt index bf1abd9fb1..d418ea8d8c 100644 --- a/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrMapperTest.kt +++ b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrMapperTest.kt @@ -5,8 +5,11 @@ import dev.dimension.flare.data.datasource.microblog.PostActionFamily import dev.dimension.flare.data.datasource.microblog.PostEvent import dev.dimension.flare.data.network.tumblr.TumblrBlog import dev.dimension.flare.data.network.tumblr.TumblrNote +import dev.dimension.flare.data.network.tumblr.TumblrNpfAttribution import dev.dimension.flare.data.network.tumblr.TumblrNpfBlock import dev.dimension.flare.data.network.tumblr.TumblrNpfFormatting +import dev.dimension.flare.data.network.tumblr.TumblrNpfLayout +import dev.dimension.flare.data.network.tumblr.TumblrNpfLayoutDisplay import dev.dimension.flare.data.network.tumblr.TumblrNpfMedia import dev.dimension.flare.data.network.tumblr.TumblrPost import dev.dimension.flare.data.network.tumblr.TumblrTrailItem @@ -90,11 +93,11 @@ class TumblrMapperTest { assertEquals("A link card", card.description) assertEquals("https://example.com/story", card.url) assertEquals("https://example.com/poster.jpg", card.media?.url) - assertEquals(4, timelinePost.actions.size) + assertEquals(3, timelinePost.actions.size) } @Test - fun actionMenusMatchTwitterStyleReplyRepostQuoteLikeMore() { + fun actionMenusExposeSupportedRepostQuoteLikeAndMoreActions() { val post = TumblrPost( idString = "123", @@ -104,6 +107,9 @@ class TumblrMapperTest { content = listOf(textBlock("Hello")), reblogKey = "abc123", noteCount = 7, + replyCount = 2, + reblogCount = 3, + likeCount = 5, notes = listOf( TumblrNote(type = "reply"), @@ -125,15 +131,9 @@ class TumblrMapperTest { val timelinePost = assertIs(post.toUiTimeline(accountKey)).post val actions = timelinePost.actions - assertEquals(4, actions.size) + assertEquals(3, actions.size) - val reply = assertIs(actions[0]) - assertEquals(PostActionFamily.Reply, reply.actionFamily) - assertEquals(2L, reply.count?.value) - val replyRoute = DeeplinkRoute.parse(assertIs(reply.clickEvent).url) - assertIs(replyRoute) - - val repostGroup = assertIs(actions[1]) + val repostGroup = assertIs(actions[0]) val repostDisplay = assertIs(repostGroup.displayItem) assertEquals(PostActionFamily.Repost, repostDisplay.actionFamily) assertEquals(3L, repostDisplay.count?.value) @@ -149,17 +149,18 @@ class TumblrMapperTest { val quoteRoute = DeeplinkRoute.parse(assertIs(quote.clickEvent).url) assertIs(quoteRoute) - val like = assertIs(actions[2]) + val like = assertIs(actions[1]) assertEquals(PostActionFamily.Like, like.actionFamily) assertEquals(5L, like.count?.value) - val more = assertIs(actions[3]) + val more = assertIs(actions[2]) val overflowFamilies = more.actions .filterIsInstance() .mapNotNull { it.actionFamily } assertEquals( listOf( + PostActionFamily.Comment, PostActionFamily.Share, PostActionFamily.MuteUser, PostActionFamily.BlockUser, @@ -633,6 +634,210 @@ class TumblrMapperTest { assertEquals("https://64.media.tumblr.com/original.jpg", assertIs(repost.images.single()).url) } + @Test + fun externalAudioPrefersPlayableMediaUrl() { + val post = + TumblrPost( + idString = "audio-external", + blogName = "staff", + content = + listOf( + TumblrNpfBlock( + type = "audio", + provider = "soundcloud", + title = "Track", + url = "https://soundcloud.com/example/track", + media = listOf(TumblrNpfMedia(type = "audio/mp3", url = "https://cdn.example.com/track.mp3")), + ), + ), + ) + + val timelinePost = assertIs(post.toUiTimeline(accountKey)).post + assertEquals("https://cdn.example.com/track.mp3", assertIs(timelinePost.images.single()).url) + } + + @Test + fun formattingOffsetsUseUnicodeCodePoints() { + val post = + TumblrPost( + idString = "unicode-formatting", + blogName = "staff", + content = + listOf( + textBlock( + text = "😀a link", + formatting = + listOf( + TumblrNpfFormatting(type = "link", start = 3, end = 7, url = "https://example.com"), + ), + ), + ), + ) + + val content = assertIs(post.toUiTimeline(accountKey)).post.content + val runs = assertIs(content.renderRuns.single()).runs.filterIsInstance() + assertEquals("😀a ", runs[0].text) + assertEquals("link", runs[1].text) + assertEquals("https://example.com", runs[1].style.link) + } + + @Test + fun completeReblogTrailIsPreservedInOrderWithRichText() { + val post = + TumblrPost( + idString = "trail-chain", + blogName = "me", + content = emptyList(), + trail = + listOf( + TumblrTrailItem( + blog = TumblrBlog(name = "root"), + post = TumblrTrailPost(id = "root-id"), + content = + listOf( + textBlock( + "Root text", + formatting = listOf(TumblrNpfFormatting(type = "bold", start = 0, end = 4)), + ), + ), + ), + TumblrTrailItem( + blog = TumblrBlog(name = "parent"), + post = TumblrTrailPost(id = "parent-id"), + content = listOf(textBlock("Parent comment")), + ), + ), + ) + + val rendered = assertIs(post.toUiTimeline(accountKey)) + assertEquals("Parent comment", assertNotNull(rendered.presentation.repost).content.raw) + assertEquals( + "Root text", + rendered.presentation.inlineParents + .single() + .content.raw, + ) + val rootRun = + assertIs( + rendered.presentation.inlineParents + .single() + .content.renderRuns + .single(), + ).runs + .filterIsInstance() + .first() + assertTrue(rootRun.style.bold) + } + + @Test + fun brokenTrailAndRowsVisibilityArePreserved() { + val post = + TumblrPost( + idString = "broken-trail", + blogName = "me", + content = listOf(textBlock("My comment")), + trail = + listOf( + TumblrTrailItem( + brokenBlogName = "gone-blog", + content = listOf(textBlock("First"), textBlock("Hidden"), textBlock("Third")), + layout = + listOf( + TumblrNpfLayout( + type = "rows", + display = listOf(TumblrNpfLayoutDisplay(blocks = listOf(2, 0))), + ), + ), + ), + ), + ) + + val quote = assertIs(post.toUiTimeline(accountKey)).presentation.quotes.single() + assertEquals("gone-blog", quote.user?.handle?.raw) + assertEquals("Third\nFirst", quote.content.raw) + assertIs(quote.clickEvent) + } + + @Test + fun imageCaptionAskGroupingAndHtmlDescriptionAreMapped() { + val post = + TumblrPost( + idString = "ask-caption", + blogName = "staff", + content = + listOf( + imageBlock( + url = "https://64.media.tumblr.com/ask.jpg", + width = 640, + height = 480, + altText = "ask", + ).copy(caption = "Image caption"), + textBlock("Question"), + textBlock("Answer"), + ), + layout = + listOf( + TumblrNpfLayout( + type = "ask", + blocks = listOf(1), + attribution = TumblrNpfAttribution(type = "blog", blog = TumblrBlog(name = "asker")), + ), + ), + ) + + val timelinePost = assertIs(post.toUiTimeline(accountKey)).post + assertEquals("Image caption\nQuestion\nAnswer", timelinePost.content.raw) + assertTrue( + timelinePost.content.renderRuns + .filterIsInstance() + .any { it.block.isBlockQuote }, + ) + + val profile = TumblrBlog(name = "staff", description = "

Hello Tumblr & friends

").toUiProfile(accountKey) + assertEquals("Hello Tumblr & friends", profile.description?.raw) + } + + @Test + fun missingBlogNameIsResolvedFromTumblrSubdomainUrl() { + val profile = TumblrBlog(url = "https://staff.tumblr.com/").toUiProfile(accountKey) + + assertEquals(tumblrUserKey("staff"), profile.key) + } + + @Test + fun noteMetadataIsNotUsedAsAnActionTotal() { + val post = + TumblrPost( + idString = "notes", + blogName = "staff", + reblogKey = "key", + noteCount = 42, + notes = listOf(TumblrNote(type = "like")), + ) + + val actions = assertIs(post.toUiTimeline(accountKey)).post.actions + assertEquals(0, assertIs(assertIs(actions[0]).displayItem).count?.value) + assertEquals(0, assertIs(actions[1]).count?.value) + val overflow = assertIs(actions[2]).actions.filterIsInstance() + val notes = overflow.single { it.actionFamily == PostActionFamily.Comment } + assertEquals(42L, notes.count?.value) + } + + @Test + fun privatePostUsesPrivateVisibility() { + val post = + TumblrPost( + idString = "private-post", + blogName = "staff", + state = "private", + content = listOf(textBlock("Private content")), + ) + + val timelinePost = assertIs(post.toUiTimeline(accountKey)).post + + assertEquals(UiTimelineV2.Post.Visibility.Private, timelinePost.visibility) + } + private fun textBlock( text: String, formatting: List = emptyList(), diff --git a/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/network/tumblr/TumblrPlatformDetectorTest.kt b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/network/tumblr/TumblrPlatformDetectorTest.kt new file mode 100644 index 0000000000..7c907e9acc --- /dev/null +++ b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/network/tumblr/TumblrPlatformDetectorTest.kt @@ -0,0 +1,29 @@ +package dev.dimension.flare.data.network.tumblr + +import dev.dimension.flare.data.platform.TUMBLR_HOST +import dev.dimension.flare.model.PlatformType +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TumblrPlatformDetectorTest { + @Test + fun detectsRootAndBlogHostsAsTumblr() = + runTest { + listOf(TUMBLR_HOST, "www.tumblr.com", "Staff.tumblr.com").forEach { host -> + val result = TumblrPlatformDetector.detect(host) + + assertEquals(TUMBLR_HOST, result?.host) + assertEquals(PlatformType.Tumblr, result?.platformType) + } + } + + @Test + fun rejectsLookalikeAndNestedHosts() = + runTest { + assertNull(TumblrPlatformDetector.detect("tumblr.com.example.org")) + assertNull(TumblrPlatformDetector.detect("not-tumblr.com")) + assertNull(TumblrPlatformDetector.detect("foo.bar.tumblr.com")) + } +} diff --git a/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/network/tumblr/TumblrServiceTest.kt b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/network/tumblr/TumblrServiceTest.kt index 75513c45b7..3b7ca9bbe0 100644 --- a/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/network/tumblr/TumblrServiceTest.kt +++ b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/network/tumblr/TumblrServiceTest.kt @@ -19,6 +19,7 @@ import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest +import kotlinx.serialization.decodeFromString import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -130,6 +131,7 @@ class TumblrServiceTest { TumblrNpfBlock( type = "video", media = listOf(TumblrNpfMedia(identifier = "video0", type = "video/mp4")), + poster = listOf(TumblrNpfMedia(url = "https://example.com/poster.jpg", type = "image/jpeg")), ), ), ), @@ -137,6 +139,17 @@ class TumblrServiceTest { assertTrue("\"media\":[{\"identifier\":\"image0\",\"type\":\"image/jpeg\"}]" in image) assertTrue("\"media\":{\"identifier\":\"video0\",\"type\":\"video/mp4\"}" in video) + assertTrue("\"poster\":[{\"type\":\"image/jpeg\",\"url\":\"https://example.com/poster.jpg\"}]" in video) + } + + @Test + fun blogInfoDecodesFollowedAndUuidOnlyBlog() { + val followed = JSON.decodeFromString("""{"name":"staff","followed":true}""") + val uuidOnly = JSON.decodeFromString("""{"uuid":"t:abc123"}""") + + assertEquals(true, followed.followed) + assertEquals(null, uuidOnly.name) + assertEquals("t:abc123", uuidOnly.uuid) } @Test @@ -161,6 +174,36 @@ class TumblrServiceTest { assertEquals("true", requests[1].url.parameters["reblog_info"]) } + @Test + fun followingUsesBlogScopedEndpoint() = + runTest { + val requests = mutableListOf() + val service = + TumblrService( + credentialFlow = MutableStateFlow(credential()), + authResources = unusedAuthResources, + resources = + mockResources { scope, request -> + requests += request + scope.respondJson( + """{"meta":{"status":200,"msg":"OK"},"response":{"blogs":[]}}""", + ) + }, + ) + + service.following( + blogIdentifier = "mtlaster", + limit = 20, + offset = 40, + ) + + val request = requests.single() + assertEquals(HttpMethod.Get, request.method) + assertEquals("/v2/blog/mtlaster/following", request.url.encodedPath) + assertEquals("20", request.url.parameters["limit"]) + assertEquals("40", request.url.parameters["offset"]) + } + @Test fun createPostDecodesNpfPostMutationResponse() = runTest { diff --git a/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/platform/TumblrPlatformSpecTest.kt b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/platform/TumblrPlatformSpecTest.kt new file mode 100644 index 0000000000..65f0b5f560 --- /dev/null +++ b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/platform/TumblrPlatformSpecTest.kt @@ -0,0 +1,27 @@ +package dev.dimension.flare.data.platform + +import dev.dimension.flare.model.MicroBlogKey +import kotlin.test.Test +import kotlin.test.assertEquals + +class TumblrPlatformSpecTest { + @Test + fun deepLinksIncludePostUrlsWithAndWithoutSlugs() { + val patterns = + TumblrPlatformSpec + .deepLinks(MicroBlogKey(id = "staff", host = TUMBLR_HOST)) + .map { it.uriPattern } + + assertEquals( + listOf( + "https://www.tumblr.com/{blogName}/{id}", + "https://www.tumblr.com/{blogName}/{id}/{slug}", + "https://{blogname}.tumblr.com/post/{id}", + "https://{blogname}.tumblr.com/post/{id}/{slug}", + "https://www.tumblr.com/{blogName}", + "https://{blogname}.tumblr.com", + ), + patterns, + ) + } +} diff --git a/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/login/TumblrLoginProviderTest.kt b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/login/TumblrLoginProviderTest.kt new file mode 100644 index 0000000000..6a16e4cbab --- /dev/null +++ b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/login/TumblrLoginProviderTest.kt @@ -0,0 +1,32 @@ +package dev.dimension.flare.ui.presenter.login + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TumblrLoginProviderTest { + @Test + fun oauthErrorCallbackCanResumeAndClearsLoading() = + runTest { + val handler = + TumblrLoginProvider.createHandler( + LoginContext( + host = "tumblr.com", + methodType = LoginMethodType.OAuth, + onSuccess = {}, + ), + ) + val callback = + "https://flareapp.moe/tumblr-callback.html" + + "?error=access_denied&error_description=User%20denied%20access&state=test-state" + + assertTrue(handler.canResume(callback)) + + handler.resume(callback) + + assertFalse(handler.state.value.loading) + assertEquals("User denied access", handler.state.value.error) + } +} From c0b7b5a90f5c1ff4738910998a3dae2c17ef2f81 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 14 Jul 2026 15:30:05 +0900 Subject: [PATCH 3/3] fix build --- .../flare/ui/presenter/login/LoginApi.kt | 53 ++++++++++++++----- .../login/LoginPlatformRegistryTest.kt | 50 +++++++++++++++++ social/tumblr/build.gradle.kts | 1 + 3 files changed, 92 insertions(+), 12 deletions(-) diff --git a/feature/login-api/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/LoginApi.kt b/feature/login-api/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/LoginApi.kt index f77c40aa0a..245e662794 100644 --- a/feature/login-api/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/LoginApi.kt +++ b/feature/login-api/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/LoginApi.kt @@ -10,8 +10,12 @@ import dev.dimension.flare.model.RecommendedInstance import dev.dimension.flare.ui.model.UiInstanceMetadata import dev.dimension.flare.ui.model.UiStrings import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.supervisorScope import org.koin.core.annotation.Provided import org.koin.core.annotation.Single import kotlin.native.HiddenFromObjC @@ -170,21 +174,46 @@ public class LoginPlatformRegistry( public suspend fun detectPlatformType(host: String): NodeData { val hostCleaned = normalizeHost(host) require(hostCleaned.isNotBlank()) { "Host is blank" } - return all - .map { it.detector } - .distinct() - .sortedByDescending { it.priority } - .firstNotNullOfOrNull { detector -> - runCatching { - detector.detect(hostCleaned) - }.getOrElse { - if (it is CancellationException) { - throw it + val detectors = + all + .map { it.detector } + .distinct() + .sortedByDescending { it.priority } + + return supervisorScope { + val pending = + detectors + .map { detector -> + async(start = CoroutineStart.UNDISPATCHED) { + try { + detector.detect(hostCleaned) + } catch (error: CancellationException) { + throw error + } catch (_: Throwable) { + null + } + } + }.toMutableList() + + try { + while (pending.isNotEmpty()) { + val (completed, detected) = + select { + pending.forEach { deferred -> + deferred.onAwait { deferred to it } + } + } + pending.remove(completed) + if (detected != null) { + return@supervisorScope detected } - null } + + throw IllegalArgumentException("Unsupported platform: $hostCleaned") + } finally { + pending.forEach { it.cancel() } } - ?: throw IllegalArgumentException("Unsupported platform: $hostCleaned") + } } private fun normalizeHost(host: String): String = diff --git a/feature/login-api/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/login/LoginPlatformRegistryTest.kt b/feature/login-api/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/login/LoginPlatformRegistryTest.kt index cef87ee2c2..8083c5b195 100644 --- a/feature/login-api/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/login/LoginPlatformRegistryTest.kt +++ b/feature/login-api/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/login/LoginPlatformRegistryTest.kt @@ -18,7 +18,10 @@ import dev.dimension.flare.ui.model.UiStrings import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -122,6 +125,51 @@ class LoginPlatformRegistryTest { assertEquals("mstdn.jp", detected.host) } + @Test + fun detectionReturnsFirstSuccessfulResultWithoutWaitingForSlowerDetectors() = + runTest { + val slowDetectorStarted = CompletableDeferred() + val slowDetectorCancelled = CompletableDeferred() + val registry = + LoginPlatformRegistry( + testRuntimeData( + testProvider( + platformType = PlatformType.Misskey, + detectorPriority = 10, + detectorBlock = { + slowDetectorStarted.complete(Unit) + try { + awaitCancellation() + } finally { + slowDetectorCancelled.complete(Unit) + } + }, + ), + testProvider( + platformType = PlatformType.Mastodon, + detectorPriority = 0, + detectorBlock = { host -> + slowDetectorStarted.await() + NodeData( + host = host, + platformType = PlatformType.Mastodon, + software = "mastodon", + compatibleMode = false, + ) + }, + ), + ), + ) + + val detected = + withTimeout(1_000) { + registry.detectPlatformType("mstdn.jp") + } + + assertEquals(PlatformType.Mastodon, detected.platformType) + slowDetectorCancelled.await() + } + @Test fun detectionDoesNotSwallowCancellation() = runTest { @@ -168,6 +216,7 @@ class LoginPlatformRegistryTest { detectorPriority: Int = 0, detectedSoftware: String? = null, detectorFailure: Throwable? = null, + detectorBlock: (suspend (String) -> NodeData?)? = null, ): LoginPlatformProvider = object : LoginPlatformProvider { override val platformType: PlatformType = platformType @@ -181,6 +230,7 @@ class LoginPlatformRegistryTest { override val priority: Int = detectorPriority override suspend fun detect(host: String): NodeData? { + detectorBlock?.let { return it(host) } detectorFailure?.let { throw it } return detectedSoftware?.let { NodeData( diff --git a/social/tumblr/build.gradle.kts b/social/tumblr/build.gradle.kts index af53ff3f6d..a66380ed30 100644 --- a/social/tumblr/build.gradle.kts +++ b/social/tumblr/build.gradle.kts @@ -67,6 +67,7 @@ kotlin { implementation(libs.okio) implementation(libs.kotlin.codepoints.deluxe) implementation(libs.paging.common) + implementation(libs.ksoup) } } val commonTest by getting {