From 644010d785f216cd8872cf45dc7567d0db7eb028 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 7 Jul 2026 12:41:51 +0900 Subject: [PATCH] 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 +}