diff --git a/.gitignore b/.gitignore
index 8090a4fb9..1c495698b 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 402637852..b01a975c8 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 a3c95d9e2..6baf96c2d 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/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt
index 1a5409ca7..a5370bb69 100644
--- a/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt
+++ b/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt
@@ -1559,6 +1559,10 @@ internal val UiTimelineV2.Post.Visibility.localName: Int
R.string.misskey_visibility_specified
}
+ UiTimelineV2.Post.Visibility.Private -> {
+ R.string.tumblr_visibility_private
+ }
+
UiTimelineV2.Post.Visibility.Channel -> {
R.string.misskey_visibility_public
}
@@ -1583,6 +1587,10 @@ internal val UiTimelineV2.Post.Visibility.localDescription: Int
R.string.misskey_visibility_specified_description
}
+ UiTimelineV2.Post.Visibility.Private -> {
+ R.string.tumblr_visibility_private_description
+ }
+
UiTimelineV2.Post.Visibility.Channel -> {
R.string.misskey_visibility_public_description
}
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index a8febf09b..183515946 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -212,6 +212,8 @@
仅此实例上的用户可以看到此动态
仅关注者可以看到此动态
仅提及的用户可以看到此动态
+ 私密
+ 不公开显示
用户名
密码
2FA 验证码
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 6a120097b..e6043d112 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -175,6 +175,8 @@
只有此站台上的使用者可以看到此貼文
只有追蹤者可以看到此貼文
只有被提及的使用者可以看到此貼文
+ 私人
+ 不公開顯示
使用者名稱
密碼
2FA 驗證碼
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 00c7d4969..7266da047 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -241,6 +241,8 @@
Only users on this instance can see this post
Only followers can see this post
Only mentioned users can see this post
+ Private
+ Not publicly visible
Username
Password
diff --git a/apple-shared/build.gradle.kts b/apple-shared/build.gradle.kts
index 118b8e254..d2b362c86 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 b3b3bf30a..c45066d18 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 f5e49734a..1d7813aea 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/FontAwesomeIcon.swift b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleCore/FontAwesomeIcon.swift
index 3b1b1c4c7..55275e7de 100644
--- a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleCore/FontAwesomeIcon.swift
+++ b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleCore/FontAwesomeIcon.swift
@@ -89,6 +89,7 @@ public enum FontAwesomeIcon: String, CaseIterable, Hashable, Identifiable, Senda
case tableList = "table-list"
case telegram = "telegram"
case thumbtack = "thumbtack"
+ case tumblr = "tumblr"
case trash = "trash"
case tv = "tv"
case twitter = "twitter"
diff --git a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleCore/PresentationMappings.swift b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleCore/PresentationMappings.swift
index 8c63625a8..2a60d05d1 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:
+ .tumblr
case .list:
.list
case .feeds, .rss:
diff --git a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/TimelineItem/StatusView.swift b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/TimelineItem/StatusView.swift
index b6b3bc707..8c6fa392b 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: .tumblr)
+ .font(.caption)
+ .foregroundStyle(.secondary)
}
}
if !isDetail {
diff --git a/appleApp/Shared/FlareAppleResource/Resources/FontAwesome.xcassets/Contents.json b/appleApp/Shared/FlareAppleResource/Resources/FontAwesome.xcassets/Contents.json
index 4aa7c5350..73c00596a 100644
--- a/appleApp/Shared/FlareAppleResource/Resources/FontAwesome.xcassets/Contents.json
+++ b/appleApp/Shared/FlareAppleResource/Resources/FontAwesome.xcassets/Contents.json
@@ -3,4 +3,4 @@
"author" : "xcode",
"version" : 1
}
-}
\ No newline at end of file
+}
diff --git a/appleApp/Shared/FlareAppleResource/Resources/FontAwesome.xcassets/tumblr.symbolset/Contents.json b/appleApp/Shared/FlareAppleResource/Resources/FontAwesome.xcassets/tumblr.symbolset/Contents.json
new file mode 100644
index 000000000..de005f1df
--- /dev/null
+++ b/appleApp/Shared/FlareAppleResource/Resources/FontAwesome.xcassets/tumblr.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "tumblr.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/appleApp/Shared/FlareAppleResource/Resources/FontAwesome.xcassets/tumblr.symbolset/tumblr.svg b/appleApp/Shared/FlareAppleResource/Resources/FontAwesome.xcassets/tumblr.symbolset/tumblr.svg
new file mode 100644
index 000000000..2fc4b68dd
--- /dev/null
+++ b/appleApp/Shared/FlareAppleResource/Resources/FontAwesome.xcassets/tumblr.symbolset/tumblr.svg
@@ -0,0 +1,84 @@
+
\ No newline at end of file
diff --git a/appleApp/ios/UI/Screen/ComposeScreen.swift b/appleApp/ios/UI/Screen/ComposeScreen.swift
index 8840e2765..ba6d8302d 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 4737cb84f..8cdc11990 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 08fbb0ac5..1128a9298 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 7d2cacebe..2dd016052 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 4e291241a..bd86ebf9f 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/component/status/CommonStatusComponent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt
index 96c464416..5142ed51e 100644
--- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt
+++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt
@@ -873,6 +873,15 @@ public fun StatusVisibilityComponent(
)
}
+ UiTimelineV2.Post.Visibility.Private -> {
+ FAIcon(
+ imageVector = FontAwesomeIcons.Solid.Lock,
+ contentDescription = stringResource(resource = Res.string.mastodon_visibility_private),
+ modifier = modifier,
+ tint = tint,
+ )
+ }
+
UiTimelineV2.Post.Visibility.Specified -> {
FAIcon(
imageVector = FontAwesomeIcons.Solid.At,
diff --git a/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 f50bfd7bf..8a1fe376c 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 040c3dc66..adf298f5b 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/composeResources/values-zh-rCN/strings.xml b/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml
index ff09354b1..eb7f85c09 100644
--- a/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml
+++ b/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml
@@ -398,6 +398,8 @@
只有本实例的用户可以看到此动态
只有您的关注者可以看到此动态
只有被提及的用户可以看到此动态
+ 私密
+ 不公开显示
订阅管理
添加订阅
编辑订阅
diff --git a/desktopApp/src/main/composeResources/values-zh-rTW/strings.xml b/desktopApp/src/main/composeResources/values-zh-rTW/strings.xml
index 171927a3c..08ee70393 100644
--- a/desktopApp/src/main/composeResources/values-zh-rTW/strings.xml
+++ b/desktopApp/src/main/composeResources/values-zh-rTW/strings.xml
@@ -344,6 +344,8 @@
僅此執行體的使用者可以看到此貼文
僅您的跟隨者可以看到此貼文
僅被提及的使用者可以看到此貼文
+ 私人
+ 不公開顯示
訂閱管理
加入訂閱
編輯訂閱
diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml
index 08aff54c7..721d36a7d 100644
--- a/desktopApp/src/main/composeResources/values/strings.xml
+++ b/desktopApp/src/main/composeResources/values/strings.xml
@@ -441,6 +441,8 @@
Only users on this instance can see this post
Only followers can see this post
Only mentioned users can see this post
+ Private
+ Not publicly visible
Subscriptions
diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopKoinApplication.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopKoinApplication.kt
index af0ed469a..fe898cce3 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/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt
index 26331223f..4f9ee0e16 100644
--- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt
+++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt
@@ -97,6 +97,8 @@ import dev.dimension.flare.misskey_visibility_specified_description
import dev.dimension.flare.model.AccountType
import dev.dimension.flare.navigate_back
import dev.dimension.flare.ok
+import dev.dimension.flare.tumblr_visibility_private
+import dev.dimension.flare.tumblr_visibility_private_description
import dev.dimension.flare.ui.component.AvatarComponent
import dev.dimension.flare.ui.component.EmojiPicker
import dev.dimension.flare.ui.component.FAIcon
@@ -1488,6 +1490,10 @@ internal val UiTimelineV2.Post.Visibility.localName: StringResource
Res.string.misskey_visibility_specified
}
+ UiTimelineV2.Post.Visibility.Private -> {
+ Res.string.tumblr_visibility_private
+ }
+
UiTimelineV2.Post.Visibility.Channel -> {
Res.string.misskey_visibility_public
}
@@ -1512,6 +1518,10 @@ internal val UiTimelineV2.Post.Visibility.localDescription: StringResource
Res.string.misskey_visibility_specified_description
}
+ UiTimelineV2.Post.Visibility.Private -> {
+ Res.string.tumblr_visibility_private_description
+ }
+
UiTimelineV2.Post.Visibility.Channel -> {
Res.string.misskey_visibility_public_description
}
diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/ComposePostTool.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/ComposePostTool.kt
index 9bb45a82e..238c6c3cb 100644
--- a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/ComposePostTool.kt
+++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/ComposePostTool.kt
@@ -95,9 +95,13 @@ internal class ComposePostTool(
return "Post content is ${content.length} characters, but ${target.platformType.name} allows " +
"at most $maxLength for ${action.label}."
}
- if (visibility != UiTimelineV2.Post.Visibility.Public && config.visibility == null) {
+ val visibilityConfig = config.visibility
+ if (visibility != UiTimelineV2.Post.Visibility.Public && visibilityConfig == null) {
return "${target.platformType.name} does not expose visibility selection for ${action.label} posts."
}
+ if (visibilityConfig != null && visibility !in visibilityConfig.allVisibilities) {
+ return "${target.platformType.name} does not support ${visibility.name} visibility for ${action.label} posts."
+ }
if (args.spoilerText.isNotBlank() && config.contentWarning == null) {
return "${target.platformType.name} does not expose content warning selection for ${action.label} posts."
}
diff --git a/feature/agent/src/commonTest/kotlin/dev/dimension/flare/feature/agent/common/AgentToolsTest.kt b/feature/agent/src/commonTest/kotlin/dev/dimension/flare/feature/agent/common/AgentToolsTest.kt
index 9ea45d4a8..23558b171 100644
--- a/feature/agent/src/commonTest/kotlin/dev/dimension/flare/feature/agent/common/AgentToolsTest.kt
+++ b/feature/agent/src/commonTest/kotlin/dev/dimension/flare/feature/agent/common/AgentToolsTest.kt
@@ -1586,7 +1586,7 @@ private class StubComposeDataSource(
override fun composeConfig(type: ComposeType): ComposeConfig =
ComposeConfig(
text = ComposeConfig.Text(maxLength = 300),
- visibility = ComposeConfig.Visibility,
+ visibility = ComposeConfig.Visibility(),
language = ComposeConfig.Language(maxCount = 1),
)
diff --git a/feature/login-api/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/LoginApi.kt b/feature/login-api/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/LoginApi.kt
index f77c40aa0..245e66279 100644
--- a/feature/login-api/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/LoginApi.kt
+++ b/feature/login-api/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/LoginApi.kt
@@ -10,8 +10,12 @@ import dev.dimension.flare.model.RecommendedInstance
import dev.dimension.flare.ui.model.UiInstanceMetadata
import dev.dimension.flare.ui.model.UiStrings
import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.selects.select
+import kotlinx.coroutines.supervisorScope
import org.koin.core.annotation.Provided
import org.koin.core.annotation.Single
import kotlin.native.HiddenFromObjC
@@ -170,21 +174,46 @@ public class LoginPlatformRegistry(
public suspend fun detectPlatformType(host: String): NodeData {
val hostCleaned = normalizeHost(host)
require(hostCleaned.isNotBlank()) { "Host is blank" }
- return all
- .map { it.detector }
- .distinct()
- .sortedByDescending { it.priority }
- .firstNotNullOfOrNull { detector ->
- runCatching {
- detector.detect(hostCleaned)
- }.getOrElse {
- if (it is CancellationException) {
- throw it
+ val detectors =
+ all
+ .map { it.detector }
+ .distinct()
+ .sortedByDescending { it.priority }
+
+ return supervisorScope {
+ val pending =
+ detectors
+ .map { detector ->
+ async(start = CoroutineStart.UNDISPATCHED) {
+ try {
+ detector.detect(hostCleaned)
+ } catch (error: CancellationException) {
+ throw error
+ } catch (_: Throwable) {
+ null
+ }
+ }
+ }.toMutableList()
+
+ try {
+ while (pending.isNotEmpty()) {
+ val (completed, detected) =
+ select {
+ pending.forEach { deferred ->
+ deferred.onAwait { deferred to it }
+ }
+ }
+ pending.remove(completed)
+ if (detected != null) {
+ return@supervisorScope detected
}
- null
}
+
+ throw IllegalArgumentException("Unsupported platform: $hostCleaned")
+ } finally {
+ pending.forEach { it.cancel() }
}
- ?: throw IllegalArgumentException("Unsupported platform: $hostCleaned")
+ }
}
private fun normalizeHost(host: String): String =
diff --git a/feature/login-api/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/login/LoginPlatformRegistryTest.kt b/feature/login-api/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/login/LoginPlatformRegistryTest.kt
index cef87ee2c..8083c5b19 100644
--- a/feature/login-api/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/login/LoginPlatformRegistryTest.kt
+++ b/feature/login-api/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/login/LoginPlatformRegistryTest.kt
@@ -18,7 +18,10 @@ import dev.dimension.flare.ui.model.UiStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.withTimeout
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
@@ -122,6 +125,51 @@ class LoginPlatformRegistryTest {
assertEquals("mstdn.jp", detected.host)
}
+ @Test
+ fun detectionReturnsFirstSuccessfulResultWithoutWaitingForSlowerDetectors() =
+ runTest {
+ val slowDetectorStarted = CompletableDeferred()
+ val slowDetectorCancelled = CompletableDeferred()
+ val registry =
+ LoginPlatformRegistry(
+ testRuntimeData(
+ testProvider(
+ platformType = PlatformType.Misskey,
+ detectorPriority = 10,
+ detectorBlock = {
+ slowDetectorStarted.complete(Unit)
+ try {
+ awaitCancellation()
+ } finally {
+ slowDetectorCancelled.complete(Unit)
+ }
+ },
+ ),
+ testProvider(
+ platformType = PlatformType.Mastodon,
+ detectorPriority = 0,
+ detectorBlock = { host ->
+ slowDetectorStarted.await()
+ NodeData(
+ host = host,
+ platformType = PlatformType.Mastodon,
+ software = "mastodon",
+ compatibleMode = false,
+ )
+ },
+ ),
+ ),
+ )
+
+ val detected =
+ withTimeout(1_000) {
+ registry.detectPlatformType("mstdn.jp")
+ }
+
+ assertEquals(PlatformType.Mastodon, detected.platformType)
+ slowDetectorCancelled.await()
+ }
+
@Test
fun detectionDoesNotSwallowCancellation() =
runTest {
@@ -168,6 +216,7 @@ class LoginPlatformRegistryTest {
detectorPriority: Int = 0,
detectedSoftware: String? = null,
detectorFailure: Throwable? = null,
+ detectorBlock: (suspend (String) -> NodeData?)? = null,
): LoginPlatformProvider =
object : LoginPlatformProvider {
override val platformType: PlatformType = platformType
@@ -181,6 +230,7 @@ class LoginPlatformRegistryTest {
override val priority: Int = detectorPriority
override suspend fun detect(host: String): NodeData? {
+ detectorBlock?.let { return it(host) }
detectorFailure?.let { throw it }
return detectedSoftware?.let {
NodeData(
diff --git a/settings.gradle.kts b/settings.gradle.kts
index f57c3b201..259bc37b6 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 98767fbbe..a5d055b7c 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/ComposeConfig.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeConfig.kt
index eab8c4ae4..c082daa2e 100644
--- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeConfig.kt
+++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeConfig.kt
@@ -4,8 +4,11 @@ import androidx.compose.runtime.Immutable
import dev.dimension.flare.common.CacheData
import dev.dimension.flare.model.MicroBlogKey
import dev.dimension.flare.ui.model.UiEmoji
+import dev.dimension.flare.ui.model.UiTimelineV2
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
@Immutable
public data class ComposeConfig public constructor(
@@ -294,7 +297,43 @@ public data class ComposeConfig public constructor(
public data object ContentWarning
@Immutable
- public data object Visibility
+ public data class Visibility public constructor(
+ val allVisibilities: ImmutableList =
+ persistentListOf(
+ UiTimelineV2.Post.Visibility.Public,
+ UiTimelineV2.Post.Visibility.Home,
+ UiTimelineV2.Post.Visibility.Followers,
+ UiTimelineV2.Post.Visibility.Specified,
+ ),
+ val defaultVisibility: UiTimelineV2.Post.Visibility = UiTimelineV2.Post.Visibility.Public,
+ ) {
+ init {
+ require(allVisibilities.isNotEmpty()) { "Compose visibility options must not be empty" }
+ require(defaultVisibility in allVisibilities) {
+ "Default compose visibility must be included in the available options"
+ }
+ }
+
+ internal fun merge(other: Visibility): Visibility? {
+ val commonVisibilities =
+ allVisibilities
+ .filter { it in other.allVisibilities }
+ .toImmutableList()
+ if (commonVisibilities.isEmpty()) {
+ return null
+ }
+ val mergedDefault =
+ defaultVisibility
+ .takeIf { it == other.defaultVisibility && it in commonVisibilities }
+ ?: UiTimelineV2.Post.Visibility.Public
+ .takeIf { it in commonVisibilities }
+ ?: commonVisibilities.first()
+ return Visibility(
+ allVisibilities = commonVisibilities,
+ defaultVisibility = mergedDefault,
+ )
+ }
+ }
internal fun merge(other: ComposeConfig): ComposeConfig {
val text =
@@ -329,7 +368,7 @@ public data class ComposeConfig public constructor(
}
val visibility =
if (visibility != null && other.visibility != null) {
- visibility
+ visibility.merge(other.visibility)
} else {
null
}
diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/PostEvent.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/PostEvent.kt
index 9d7c84eee..08e0681a0 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 de3ab0244..b44e4a67a 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 b0c2a2fb6..e6d273ac4 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/UiTimelineV2.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimelineV2.kt
index 1324e09c8..3d1be8212 100644
--- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimelineV2.kt
+++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimelineV2.kt
@@ -361,6 +361,7 @@ public sealed class UiTimelineV2 {
Home,
Followers,
Specified,
+ Private,
Channel,
}
}
diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/TumblrActionMenu.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/TumblrActionMenu.kt
new file mode 100644
index 000000000..2f03711dd
--- /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/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt
index 709a32862..081acc2d8 100644
--- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt
+++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt
@@ -493,8 +493,8 @@ public class ComposePresenter(
composeConfig
.mapNotNull {
it.visibility
- }.map {
- visibilityPresenter()
+ }.map { visibilityConfig ->
+ visibilityPresenter(visibilityConfig)
}
val textMaxLength =
composeConfig
@@ -797,33 +797,32 @@ public class ComposePresenter(
}
@Composable
- private fun visibilityPresenter(): VisibilityState {
+ private fun visibilityPresenter(config: ComposeConfig.Visibility): VisibilityState {
var showVisibilityMenu by remember {
mutableStateOf(false)
}
var visibility by remember {
- mutableStateOf(UiTimelineV2.Post.Visibility.Public)
+ mutableStateOf(config.defaultVisibility)
}
var hasExplicitVisibility by remember {
mutableStateOf(false)
}
- LaunchedEffect(Unit) {
- appDataStore.composeConfigData.data.firstOrNull()?.let { data ->
- if (!hasExplicitVisibility) {
- visibility = data.visibility
+ LaunchedEffect(config) {
+ val candidate =
+ if (hasExplicitVisibility) {
+ visibility
+ } else {
+ appDataStore.composeConfigData.data
+ .firstOrNull()
+ ?.visibility
}
- }
+ visibility = candidate?.takeIf { it in config.allVisibilities } ?: config.defaultVisibility
}
+ val resolvedVisibility = visibility.takeIf { it in config.allVisibilities } ?: config.defaultVisibility
return object : VisibilityState {
- override val visibility = visibility
-
- override val allVisibilities =
- persistentListOf(
- UiTimelineV2.Post.Visibility.Public,
- UiTimelineV2.Post.Visibility.Home,
- UiTimelineV2.Post.Visibility.Followers,
- UiTimelineV2.Post.Visibility.Specified,
- )
+ override val visibility = resolvedVisibility
+
+ override val allVisibilities = config.allVisibilities
override val showVisibilityMenu: Boolean
get() = showVisibilityMenu
@@ -838,11 +837,11 @@ public class ComposePresenter(
override fun setVisibility(value: UiTimelineV2.Post.Visibility) {
hasExplicitVisibility = true
- visibility = value
+ visibility = value.takeIf { it in config.allVisibilities } ?: config.defaultVisibility
}
override fun clear() {
- visibility = UiTimelineV2.Post.Visibility.Public
+ visibility = config.defaultVisibility
showVisibilityMenu = false
hasExplicitVisibility = true
}
diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeConfigTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeConfigTest.kt
new file mode 100644
index 000000000..dd87bd145
--- /dev/null
+++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeConfigTest.kt
@@ -0,0 +1,71 @@
+package dev.dimension.flare.data.datasource.microblog
+
+import dev.dimension.flare.ui.model.UiTimelineV2
+import kotlinx.collections.immutable.persistentListOf
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+
+class ComposeConfigTest {
+ @Test
+ fun defaultVisibilityOptionsDoNotExposePlatformSpecificPrivatePosts() {
+ assertFalse(UiTimelineV2.Post.Visibility.Private in ComposeConfig.Visibility().allVisibilities)
+ }
+
+ @Test
+ fun visibilityMergeKeepsOnlyCommonOptions() {
+ val first =
+ visibility(
+ UiTimelineV2.Post.Visibility.Home,
+ UiTimelineV2.Post.Visibility.Public,
+ UiTimelineV2.Post.Visibility.Followers,
+ default = UiTimelineV2.Post.Visibility.Home,
+ )
+ val second =
+ visibility(
+ UiTimelineV2.Post.Visibility.Public,
+ UiTimelineV2.Post.Visibility.Followers,
+ default = UiTimelineV2.Post.Visibility.Followers,
+ )
+
+ val merged = first.merge(second)
+
+ assertEquals(
+ persistentListOf(
+ UiTimelineV2.Post.Visibility.Public,
+ UiTimelineV2.Post.Visibility.Followers,
+ ),
+ merged?.allVisibilities,
+ )
+ assertEquals(UiTimelineV2.Post.Visibility.Public, merged?.defaultVisibility)
+ }
+
+ @Test
+ fun visibilityMergeReturnsNullWhenThereIsNoCommonOption() {
+ val first = visibility(UiTimelineV2.Post.Visibility.Home)
+ val second = visibility(UiTimelineV2.Post.Visibility.Followers)
+
+ assertNull(first.merge(second))
+ }
+
+ @Test
+ fun visibilityRequiresDefaultToBeAvailable() {
+ assertFailsWith {
+ ComposeConfig.Visibility(
+ allVisibilities = persistentListOf(UiTimelineV2.Post.Visibility.Public),
+ defaultVisibility = UiTimelineV2.Post.Visibility.Home,
+ )
+ }
+ }
+
+ private fun visibility(
+ vararg values: UiTimelineV2.Post.Visibility,
+ default: UiTimelineV2.Post.Visibility = values.first(),
+ ): ComposeConfig.Visibility =
+ ComposeConfig.Visibility(
+ allVisibilities = persistentListOf(*values),
+ defaultVisibility = default,
+ )
+}
diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datastore/PlatformOAuthPendingRepositoryTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datastore/PlatformOAuthPendingRepositoryTest.kt
index f3c52c340..ccaeed119 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/mastodon/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt b/social/mastodon/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt
index 42f4936af..2e00e3c11 100644
--- a/social/mastodon/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt
+++ b/social/mastodon/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt
@@ -313,6 +313,7 @@ internal open class MastodonDataSource(
UiTimelineV2.Post.Visibility.Home -> Visibility.Unlisted
UiTimelineV2.Post.Visibility.Followers -> Visibility.Private
UiTimelineV2.Post.Visibility.Specified -> Visibility.Direct
+ UiTimelineV2.Post.Visibility.Private -> error("Mastodon does not support private compose visibility")
UiTimelineV2.Post.Visibility.Channel -> Visibility.Public
},
inReplyToID = inReplyToID,
@@ -458,7 +459,7 @@ internal open class MastodonDataSource(
accountKey = accountKey,
),
contentWarning = ComposeConfig.ContentWarning,
- visibility = ComposeConfig.Visibility,
+ visibility = ComposeConfig.Visibility(),
language = ComposeConfig.Language(1),
)
diff --git a/social/misskey/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt b/social/misskey/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt
index d9fc50d7a..ea7d9af44 100644
--- a/social/misskey/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt
+++ b/social/misskey/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt
@@ -478,6 +478,7 @@ internal class MisskeyDataSource(
UiTimelineV2.Post.Visibility.Home -> "home"
UiTimelineV2.Post.Visibility.Followers -> "followers"
UiTimelineV2.Post.Visibility.Specified -> "specified"
+ UiTimelineV2.Post.Visibility.Private -> error("Misskey does not support private compose visibility")
UiTimelineV2.Post.Visibility.Channel -> "public"
},
renoteId = renoteId,
@@ -585,7 +586,7 @@ internal class MisskeyDataSource(
accountKey = accountKey,
),
contentWarning = ComposeConfig.ContentWarning,
- visibility = ComposeConfig.Visibility,
+ visibility = ComposeConfig.Visibility(),
)
fun favouriteState(statusKey: MicroBlogKey): Flow =
diff --git a/social/tumblr/build.gradle.kts b/social/tumblr/build.gradle.kts
new file mode 100644
index 000000000..a66380ed3
--- /dev/null
+++ b/social/tumblr/build.gradle.kts
@@ -0,0 +1,131 @@
+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)
+ implementation(libs.ksoup)
+ }
+ }
+ 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 000000000..8a1c45451
--- /dev/null
+++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrDataSource.kt
@@ -0,0 +1,380 @@
+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,
+ blogKey = userKey,
+ )
+
+ 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
+ require(referenceStatus !is ComposeStatus.Reply) {
+ "Tumblr replies are not supported by the public API"
+ }
+ if (referenceStatus is ComposeStatus.Quote) {
+ require(data.medias.isEmpty()) { "Tumblr reblog compose media is not supported" }
+ val comment = data.content.trim()
+ require(comment.isNotEmpty()) { "Tumblr reblog comment is empty" }
+ 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(
+ allVisibilities =
+ persistentListOf(
+ UiTimelineV2.Post.Visibility.Public,
+ UiTimelineV2.Post.Visibility.Private,
+ ),
+ defaultVisibility = UiTimelineV2.Post.Visibility.Public,
+ ),
+ )
+
+ 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"
+ }
+
+internal fun UiTimelineV2.Post.Visibility.toTumblrState(): String =
+ when (this) {
+ UiTimelineV2.Post.Visibility.Public -> "published"
+ UiTimelineV2.Post.Visibility.Private -> "private"
+ else -> error("Tumblr does not support $this post visibility")
+ }
diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrKeys.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrKeys.kt
new file mode 100644
index 000000000..380f70ec1
--- /dev/null
+++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrKeys.kt
@@ -0,0 +1,71 @@
+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 {
+ val value =
+ trim()
+ .lowercase()
+ .removePrefix("https://")
+ .removePrefix("http://")
+ .substringBefore('?')
+ .substringBefore('#')
+ .removeSuffix("/")
+ val host = value.substringBefore('/')
+ val path = value.substringAfter('/', missingDelimiterValue = "")
+
+ return when {
+ host == TUMBLR_HOST || host == "www.$TUMBLR_HOST" -> {
+ path.substringBefore('/').removePrefix("@")
+ }
+
+ host.endsWith(".$TUMBLR_HOST") -> {
+ host
+ .removeSuffix(".$TUMBLR_HOST")
+ .removePrefix("www.")
+ }
+
+ host.contains('.') -> {
+ host
+ }
+
+ else -> {
+ host.removePrefix("@")
+ }
+ }
+}
+
+internal fun tumblrBlogUrl(blogName: String): String = "https://${blogName.normalizedTumblrBlogName()}.tumblr.com/"
+
+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 000000000..fdfae4f96
--- /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.followed == 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 000000000..643b0dfd5
--- /dev/null
+++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrLoaders.kt
@@ -0,0 +1,261 @@
+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}_${if (mediaOnly) "media" else "posts"}_$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,
+ paginationItemCount = response.posts.size,
+ )
+ }
+}
+
+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,
+ private val blogKey: MicroBlogKey,
+) : CacheableRemoteLoader {
+ override val pagingKey: String = "tumblr_following_${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.following(
+ blogIdentifier = blogKey.toTumblrBlogIdentifier(),
+ limit = pageSize.coercePageSize(),
+ offset = 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,
+ paginationItemCount: Int = size,
+): PagingResult {
+ val nextKey = nextOffset.takeIf { paginationItemCount >= 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 000000000..549cac312
--- /dev/null
+++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrMapper.kt
@@ -0,0 +1,1126 @@
+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.TumblrNpfBlock
+import dev.dimension.flare.data.network.tumblr.TumblrNpfFormatting
+import dev.dimension.flare.data.network.tumblr.TumblrNpfFormattingBlog
+import dev.dimension.flare.data.network.tumblr.TumblrNpfLayout
+import dev.dimension.flare.data.network.tumblr.TumblrNpfMedia
+import dev.dimension.flare.data.network.tumblr.TumblrPost
+import dev.dimension.flare.data.network.tumblr.TumblrTrailItem
+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.parseHtml
+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(layout).ifBlank { fallbackTextParts().joinToString(separator = "\n") }.trim()
+ val shouldMoveRootTagsToQuote = baseText.isBlank() && tags.isNotEmpty() && trail.isNotEmpty()
+ val renderedContent = renderContent(tags = if (shouldMoveRootTagsToQuote) emptyList() else tags)
+ val ownText = renderedContent.text
+ val createdAt = createdAtInstant().toUi()
+ val hasReblogComment = baseText.isNotBlank() || shouldMoveRootTagsToQuote
+ val referencedTrailPosts =
+ collectReferencedTrailPosts(
+ accountKey = accountKey,
+ parentStatusKey = statusKey,
+ fallbackCreatedAt = createdAt,
+ fallbackTags = if (shouldMoveRootTagsToQuote) tags else emptyList(),
+ )
+ val quotes = referencedTrailPosts.takeIf { hasReblogComment }.orEmpty()
+ val quoteMediaKeys = quotes.flatMapTo(mutableSetOf()) { it.mediaDeduplicationKeys() }
+ val postMedia = renderedContent.media.filterNot { it.deduplicationKey() in quoteMediaKeys }
+ val actions = actionMenus(accountKey = accountKey, statusKey = statusKey).toPersistentList()
+ val post =
+ 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 =
+ if (state == "private") {
+ UiTimelineV2.Post.Visibility.Private
+ } else {
+ UiTimelineV2.Post.Visibility.Public
+ },
+ references =
+ trail
+ .mapNotNull { trailItem ->
+ val trailPostId = trailItem.post?.id ?: return@mapNotNull null
+ val trailBlogName = trailItem.resolvedBlogName() ?: return@mapNotNull null
+ 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 =
+ referencedTrailPosts
+ .lastOrNull()
+ ?.takeUnless { hasReblogComment }
+ ?.copy(actions = actions)
+ return UiTimelineV2.TimelinePostItem(
+ post = post,
+ presentation =
+ UiTimelineV2.PostPresentation(
+ message =
+ repost?.let {
+ repostMessage(
+ accountKey = accountKey,
+ statusKey = statusKey,
+ createdAt = createdAt,
+ )
+ },
+ inlineParents =
+ referencedTrailPosts
+ .dropLast(if (repost == null) 0 else 1)
+ .takeUnless { hasReblogComment }
+ .orEmpty()
+ .toPersistentList(),
+ quotes = quotes.toPersistentList(),
+ repost = repost,
+ ),
+ )
+}
+
+internal fun TumblrBlog.toUiProfile(accountKey: MicroBlogKey): UiProfile {
+ val blogName = resolvedBlogName() ?: "unknown-tumblr-blog"
+ 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() }?.toTumblrDescriptionRichText(),
+ matrices =
+ UiProfile.Matrices(
+ fansCount = followers ?: 0,
+ followsCount = 0,
+ statusesCount = totalPosts ?: posts ?: 0,
+ ),
+ mark = persistentListOf(),
+ bottomContent = null,
+ )
+}
+
+private fun TumblrBlog.resolvedBlogName(): String? =
+ name
+ ?.normalizedTumblrBlogName()
+ ?.takeIf { it.isNotBlank() }
+ ?: url
+ ?.normalizedTumblrBlogName()
+ ?.takeIf { it.isNotBlank() && it != "tumblr.com" }
+ ?: uuid?.takeIf { it.isNotBlank() }?.lowercase()
+
+private fun TumblrTrailItem.resolvedBlogName(): String? =
+ blog?.resolvedBlogName()
+ ?: brokenBlogName
+ ?.normalizedTumblrBlogName()
+ ?.takeIf { it.isNotBlank() }
+
+private fun String.toTumblrDescriptionRichText(): UiRichText = parseHtml(this).toUi()
+
+private fun TumblrPost.repostMessage(
+ accountKey: MicroBlogKey,
+ statusKey: MicroBlogKey,
+ 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 {
+ 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 {
+ noteCount?.let { totalNotes ->
+ add(
+ ActionMenu.Item(
+ icon = UiIcon.Comment,
+ text = ActionMenu.Item.Text.Raw("Notes"),
+ count = UiNumber(totalNotes),
+ clickEvent = ClickEvent.Noop,
+ actionFamily = PostActionFamily.Comment,
+ ),
+ )
+ }
+ val shareUrl = postUrl ?: shortUrl
+ if (shareUrl != null) {
+ add(
+ 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.reblogActionCount: Long
+ get() =
+ reblogCount
+ ?: reblogsCount
+ ?: 0
+
+private val TumblrPost.likeActionCount: Long
+ get() =
+ likeCount
+ ?: likesCount
+ ?: 0
+
+internal fun TumblrPost.resolvedId(): String = idString ?: id?.toString() ?: error("Tumblr post id is missing")
+
+internal fun TumblrPost.resolvedBlogName(): String =
+ blogName?.normalizedTumblrBlogName()
+ ?: blog?.resolvedBlogName()
+ ?: 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,
+ layout = layout,
+ 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,
+ layout = layout,
+ tags = tags.ifEmpty { post?.tags.orEmpty() }.ifEmpty { fallbackTags },
+ )
+
+private fun renderContent(
+ content: List,
+ layout: List,
+ tags: List,
+ fallbackTextParts: List = emptyList(),
+ photos: List = emptyList(),
+): TumblrRenderedPostContent {
+ val orderedContent = content.inLayoutDisplayOrder(layout)
+ val visibleContent = orderedContent.map { it.value }
+ val tagLine = tags.toTumblrTagLine()
+ val text =
+ visibleContent
+ .collectText()
+ .ifBlank {
+ fallbackTextParts.joinToString(separator = "\n")
+ }.appendTumblrTagLine(tagLine)
+ val shouldInlineImages = visibleContent.shouldInlineAllImages()
+ val renderedNpfContent =
+ orderedContent.renderNpfContent(
+ inlineImages = shouldInlineImages,
+ askLayout = layout.firstOrNull { it.type == "ask" },
+ )
+ val fallbackTextRuns =
+ fallbackTextParts.mapNotNull { part ->
+ part.takeIf { it.isNotBlank() }?.toRenderTextContent()
+ }
+ 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 = visibleContent.collectCard(),
+ )
+}
+
+private fun List.inLayoutDisplayOrder(layout: List): List> {
+ val rowsLayout = layout.firstOrNull { it.type == "rows" }
+ val orderedIndexes =
+ rowsLayout
+ ?.let { rows ->
+ rows.display
+ .flatMap { it.blocks }
+ .ifEmpty { rows.rows.flatten() }
+ }.orEmpty()
+ .distinct()
+ val indexes =
+ if (rowsLayout != null && orderedIndexes.isNotEmpty()) {
+ // A rows layout also controls visibility (for example paywall blocks).
+ orderedIndexes
+ } else {
+ indices.toList()
+ }
+ return indexes.mapNotNull { index -> getOrNull(index)?.let { IndexedValue(index, it) } }
+}
+
+private fun String.appendTumblrTagLine(tagLine: String): String {
+ val text = trim()
+ return when {
+ 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.collectReferencedTrailPosts(
+ accountKey: MicroBlogKey,
+ parentStatusKey: MicroBlogKey,
+ fallbackCreatedAt: UiDateTime,
+ fallbackTags: List,
+): List =
+ trail.mapIndexedNotNull { index, trailItem ->
+ trailItem.toReferencedPost(
+ accountKey = accountKey,
+ parentStatusKey = parentStatusKey,
+ trailIndex = index,
+ fallbackCreatedAt = fallbackCreatedAt,
+ fallbackTags = fallbackTags,
+ )
+ }
+
+private fun TumblrTrailItem.toReferencedPost(
+ accountKey: MicroBlogKey,
+ parentStatusKey: MicroBlogKey,
+ trailIndex: Int,
+ fallbackCreatedAt: UiDateTime,
+ fallbackTags: List,
+): UiTimelineV2.Post? {
+ val trailBlogName = resolvedBlogName() ?: return null
+ val trailBlog = blog ?: TumblrBlog(name = trailBlogName)
+ val resolvablePostId = post?.id
+ val trailPostId = resolvablePostId ?: "broken-${parentStatusKey.id.substringAfterLast(':')}-$trailIndex"
+ val statusKey = tumblrPostKey(trailBlogName, trailPostId)
+ val content = renderContent(fallbackTags = fallbackTags)
+
+ 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.richText,
+ actions = persistentListOf(),
+ poll = null,
+ statusKey = statusKey,
+ card = content.card,
+ createdAt = post?.timestamp?.let(Instant::fromEpochSeconds)?.toUi() ?: fallbackCreatedAt,
+ visibility = UiTimelineV2.Post.Visibility.Public,
+ references = persistentListOf(),
+ clickEvent =
+ if (resolvablePostId == null) {
+ ClickEvent.Noop
+ } else {
+ ClickEvent.Deeplink(
+ DeeplinkRoute.Status.Detail(
+ statusKey = statusKey,
+ accountType = AccountType.Specific(accountKey),
+ ),
+ )
+ },
+ accountType = AccountType.Specific(accountKey),
+ itemKey = "tumblr_quote_${statusKey.id}",
+ )
+}
+
+private fun List>.renderNpfContent(
+ inlineImages: Boolean,
+ askLayout: TumblrNpfLayout?,
+): TumblrNpfRenderedContent {
+ val renderRuns = mutableListOf()
+ val media = mutableListOf()
+ val askBlockIndexes = askLayout?.blocks.orEmpty().toSet()
+ var askAttributionRendered = false
+ forEach { indexedBlock ->
+ val block = indexedBlock.value
+ val isAskBlock = indexedBlock.index in askBlockIndexes
+ if (isAskBlock && !askAttributionRendered) {
+ askAttributionRendered = true
+ askLayout
+ ?.attribution
+ ?.blog
+ ?.resolvedBlogName()
+ ?.let { blogName ->
+ renderRuns +=
+ RenderContent.Text(
+ runs = persistentListOf(RenderRun.Text("@$blogName")),
+ block = RenderBlockStyle(isBlockQuote = true),
+ )
+ }
+ }
+ when (block.type) {
+ "image" -> {
+ val image = block.toBestImageMedia(block.altText)
+ if (image != null) {
+ if (inlineImages) {
+ renderRuns +=
+ RenderContent.BlockImage(
+ url = image.url,
+ href = block.url,
+ )
+ } else {
+ media += image
+ }
+ }
+ block.caption
+ ?.takeIf { it.isNotBlank() }
+ ?.let { caption ->
+ renderRuns +=
+ RenderContent.Text(
+ runs = persistentListOf(RenderRun.Text(caption)),
+ block = RenderBlockStyle(isFigCaption = true),
+ )
+ }
+ }
+
+ "video" -> {
+ 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 { content ->
+ if (isAskBlock) {
+ content.copy(block = content.block.copy(isBlockQuote = true))
+ } else {
+ content
+ }
+ }?.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::charIndexForCodePointOffset) ?: return@mapNotNull null
+ val end = formatting.end?.let(text::charIndexForCodePointOffset) ?: 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.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.collectText(layout: List): String =
+ inLayoutDisplayOrder(layout)
+ .map { it.value }
+ .collectText()
+
+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)
+ }
+ }
+
+ "image" -> {
+ val caption = block.caption
+ if (!caption.isNullOrBlank()) {
+ if (builder.isNotEmpty()) builder.append('\n')
+ builder.append(caption)
+ }
+ }
+
+ else -> {
+ val text = block.text ?: block.title
+ if (!text.isNullOrBlank()) {
+ 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? =
+ media
+ .firstOrNull { item ->
+ val mediaUrl = item.url ?: return@firstOrNull false
+ item.type?.startsWith("audio/", ignoreCase = true) == true || mediaUrl.isLikelyPlayableAudioUrl()
+ }?.url
+ ?: media.firstNotNullOfOrNull { it.url }
+ ?: url?.takeIf { it.isLikelyPlayableAudioUrl() }
+
+private fun String.isLikelyPlayableAudioUrl(): Boolean {
+ val path = substringBefore('?').substringBefore('#').lowercase()
+ return path.endsWith(".mp3") ||
+ path.endsWith(".m4a") ||
+ path.endsWith(".aac") ||
+ path.endsWith(".ogg") ||
+ path.endsWith(".oga") ||
+ path.endsWith(".wav") ||
+ path.endsWith(".flac")
+}
+
+private fun String.isLikelyPlayableVideoUrl(
+ type: String?,
+ 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 000000000..eeadecbdd
--- /dev/null
+++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrModels.kt
@@ -0,0 +1,554 @@
+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? = null,
+ 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 followed: 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,
+ val state: 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("broken_blog_name")
+ val brokenBlogName: String? = null,
+ @SerialName("post")
+ val post: TumblrTrailPost? = null,
+)
+
+@Serializable
+internal data class TumblrTrailPost(
+ val id: String? = null,
+ val timestamp: Long? = 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 caption: String? = null,
+ val url: String? = null,
+ val description: String? = null,
+ val author: String? = null,
+ @SerialName("site_name")
+ val siteName: String? = null,
+ val artist: String? = null,
+ val album: String? = null,
+ val provider: String? = null,
+ @SerialName("embed_url")
+ val embedUrl: String? = null,
+ val media: List = emptyList(),
+ val poster: List = emptyList(),
+ @SerialName("alt_text")
+ val altText: String? = null,
+ @SerialName("thumbnail_url")
+ val thumbnailUrl: String? = null,
+ val width: Int? = null,
+ val height: Int? = null,
+ @SerialName("indent_level")
+ val indentLevel: Int? = null,
+ @SerialName("embed_iframe")
+ val embedIframe: TumblrNpfEmbedIframe? = null,
+ val raw: JsonObject? = null,
+)
+
+@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,
+ val display: List = emptyList(),
+ // Older trail payloads used `rows` directly instead of `display`.
+ val rows: List> = emptyList(),
+ val blocks: List = emptyList(),
+ @SerialName("truncate_after")
+ val truncateAfter: Int? = null,
+ val attribution: TumblrNpfAttribution? = null,
+)
+
+@Serializable
+internal data class TumblrNpfLayoutDisplay(
+ val blocks: List = emptyList(),
+ val mode: TumblrNpfLayoutMode? = null,
+)
+
+@Serializable
+internal data class TumblrNpfLayoutMode(
+ val type: String? = null,
+)
+
+@Serializable
+internal data class TumblrNpfAttribution(
+ val type: String? = null,
+ val url: String? = null,
+ val blog: TumblrBlog? = null,
+ val post: TumblrTrailPost? = null,
+)
+
+internal object TumblrNpfBlockSerializer : KSerializer {
+ 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"),
+ caption = obj.stringOrNull("caption"),
+ url = obj.stringOrNull("url"),
+ description = obj.stringOrNull("description"),
+ author = obj.stringOrNull("author"),
+ siteName = obj.stringOrNull("site_name"),
+ artist = obj.stringOrNull("artist"),
+ album = obj.stringOrNull("album"),
+ provider = obj.stringOrNull("provider"),
+ embedUrl = obj.stringOrNull("embed_url"),
+ media = obj.mediaList("media"),
+ poster = obj.mediaList("poster"),
+ altText = obj.stringOrNull("alt_text"),
+ thumbnailUrl = obj.stringOrNull("thumbnail_url"),
+ width = obj.intOrNull("width"),
+ height = obj.intOrNull("height"),
+ indentLevel = obj.intOrNull("indent_level"),
+ embedIframe = obj.objectOrNull("embed_iframe")?.let { TumblrNpfEmbedIframe(url = it.stringOrNull("url")) },
+ raw = obj,
+ )
+ }
+
+ 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.caption?.let { put("caption", it) }
+ value.url?.let { put("url", it) }
+ value.description?.let { put("description", it) }
+ value.author?.let { put("author", it) }
+ value.siteName?.let { put("site_name", it) }
+ value.artist?.let { put("artist", it) }
+ value.album?.let { put("album", it) }
+ value.provider?.let { put("provider", it) }
+ value.embedUrl?.let { put("embed_url", it) }
+ if (value.media.isNotEmpty()) {
+ put("media", value.media.toNpfMediaJsonElement(asArray = value.type != "video" && value.type != "audio"))
+ }
+ if (value.poster.isNotEmpty()) {
+ put("poster", value.poster.toNpfMediaJsonElement(asArray = true))
+ }
+ value.altText?.let { put("alt_text", it) }
+ value.thumbnailUrl?.let { put("thumbnail_url", it) }
+ value.width?.let { put("width", it) }
+ value.height?.let { put("height", it) }
+ value.indentLevel?.let { put("indent_level", it) }
+ value.embedIframe?.url?.let { url ->
+ put(
+ "embed_iframe",
+ buildJsonObject {
+ put("url", url)
+ },
+ )
+ }
+ },
+ )
+ }
+}
+
+private fun List.toNpfMediaJsonElement(asArray: Boolean) =
+ if (asArray) {
+ JsonArray(map { tumblrNpfMediaJsonObject(it) })
+ } else {
+ tumblrNpfMediaJsonObject(first())
+ }
+
+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 000000000..91fdd9726
--- /dev/null
+++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrPlatformDetector.kt
@@ -0,0 +1,32 @@
+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 (!host.isTumblrHost()) {
+ return null
+ }
+ return NodeData(
+ host = TUMBLR_HOST,
+ platformType = PlatformType.Tumblr,
+ software = PlatformType.Tumblr.name,
+ compatibleMode = false,
+ )
+ }
+}
+
+private fun String.isTumblrHost(): Boolean {
+ val normalized = lowercase()
+ if (normalized == TUMBLR_HOST || normalized == TUMBLR_WEB_HOST) {
+ return true
+ }
+ val blogName = normalized.removeSuffix(".$TUMBLR_HOST")
+ return blogName != normalized && blogName.isNotBlank() && !blogName.contains('.')
+}
diff --git a/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrResources.kt b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrResources.kt
new file mode 100644
index 000000000..f5c149853
--- /dev/null
+++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrResources.kt
@@ -0,0 +1,163 @@
+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("blog/{blogIdentifier}/following")
+ suspend fun following(
+ @Header("Authorization") authorization: String,
+ @Path("blogIdentifier") blogIdentifier: 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 000000000..127213ffb
--- /dev/null
+++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrService.kt
@@ -0,0 +1,311 @@
+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(
+ blogIdentifier: String,
+ limit: Int,
+ offset: Int?,
+ ): TumblrBlogPage =
+ resources
+ .following(
+ authorization = authorization(),
+ blogIdentifier = blogIdentifier,
+ 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 000000000..14378174e
--- /dev/null
+++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/data/platform/TumblrPlatformSpec.kt
@@ -0,0 +1,218 @@
+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.social.tumblr.TumblrBuildConfig
+import dev.dimension.flare.ui.model.UiIcon
+import dev.dimension.flare.ui.presenter.login.LoginPlatformProvider
+import dev.dimension.flare.ui.presenter.login.TumblrLoginProvider
+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
+ public val isConfigured: Boolean
+ get() = TumblrBuildConfig.configured
+
+ 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}"),
+ tumblrWebPostWithSlugDeepLink(accountKey, "https://www.tumblr.com/{blogName}/{id}/{slug}"),
+ tumblrSubdomainPostDeepLink(accountKey, "https://{blogname}.tumblr.com/post/{id}"),
+ tumblrSubdomainPostWithSlugDeepLink(accountKey, "https://{blogname}.tumblr.com/post/{id}/{slug}"),
+ tumblrWebProfileDeepLink(accountKey, "https://www.tumblr.com/{blogName}"),
+ tumblrSubdomainProfileDeepLink(accountKey, "https://{blogname}.tumblr.com"),
+ )
+
+ 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 tumblrWebPostWithSlugDeepLink(
+ accountKey: MicroBlogKey,
+ uriPattern: String,
+): PlatformDeepLink =
+ PlatformDeepLink(
+ uriPattern = uriPattern,
+ serializer = TumblrWebPostWithSlugDeepLink.serializer(),
+ callback = { data ->
+ DeeplinkRoute.Status.Detail(
+ accountType = AccountType.Specific(accountKey),
+ statusKey = tumblrPostKey(data.blogName, data.id),
+ )
+ },
+ )
+
+private fun tumblrSubdomainPostWithSlugDeepLink(
+ accountKey: MicroBlogKey,
+ uriPattern: String,
+): PlatformDeepLink =
+ PlatformDeepLink(
+ uriPattern = uriPattern,
+ serializer = TumblrSubdomainPostWithSlugDeepLink.serializer(),
+ matcher = { data -> !data.blogName.equals("www", ignoreCase = true) },
+ callback = { data ->
+ DeeplinkRoute.Status.Detail(
+ accountType = AccountType.Specific(accountKey),
+ statusKey = tumblrPostKey(data.blogName, data.id),
+ )
+ },
+ )
+
+private fun tumblrWebProfileDeepLink(
+ accountKey: MicroBlogKey,
+ uriPattern: String,
+): 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 TumblrWebPostWithSlugDeepLink(
+ val blogName: String,
+ val id: String,
+ val slug: String,
+)
+
+@Serializable
+private data class TumblrSubdomainPostWithSlugDeepLink(
+ @SerialName("blogname")
+ val blogName: String,
+ val id: String,
+ val slug: String,
+)
+
+@Serializable
+private data class TumblrWebProfileDeepLink(
+ val blogName: String,
+)
+
+@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 000000000..33ea49ad3
--- /dev/null
+++ b/social/tumblr/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/TumblrLoginProvider.kt
@@ -0,0 +1,293 @@
+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)
+ parsedUrl.parameters["error"]?.let { oauthError ->
+ error(parsedUrl.parameters["error_description"] ?: oauthError)
+ }
+ val code = parsedUrl.parameters["code"] ?: error("Missing Tumblr OAuth code")
+ val state = parsedUrl.parameters["state"] ?: error("Missing Tumblr OAuth state")
+ val pending =
+ 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
+ .mapNotNull { blog ->
+ blog.name
+ ?.takeIf { it.isNotBlank() }
+ ?.let { blogName -> blog to blogName }
+ }
+ require(blogs.isNotEmpty()) { "Tumblr account has no named blogs" }
+ val target = context.reloginTarget
+ blogs.forEach { (blog, blogName) ->
+ val accountKey = MicroBlogKey(id = blogName, 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 = blogName,
+ blogName = blogName,
+ blogUrl = blog.url ?: "https://$blogName.tumblr.com/",
+ blogUuid = blog.uuid,
+ isPrimary = blog.primary == true,
+ ),
+ serializer = TumblrCredential.serializer(),
+ )
+ }
+ if (target != null &&
+ blogs.none { (_, blogName) -> MicroBlogKey(id = blogName, 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["state"] != null &&
+ (parsed.parameters["code"] != null || parsed.parameters["error"] != 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 000000000..ebebf8b49
--- /dev/null
+++ b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrDataSourceTest.kt
@@ -0,0 +1,115 @@
+package dev.dimension.flare.data.datasource.tumblr
+
+import dev.dimension.flare.data.datasource.microblog.ComposeData
+import dev.dimension.flare.data.datasource.microblog.ComposeType
+import dev.dimension.flare.data.model.appearance.AppearanceKeys
+import dev.dimension.flare.data.model.tab.ShortcutSpec
+import dev.dimension.flare.data.model.tab.TimelineCandidate
+import dev.dimension.flare.data.platform.TumblrCredential
+import dev.dimension.flare.model.MicroBlogKey
+import dev.dimension.flare.ui.model.UiTimelineV2
+import dev.dimension.flare.ui.presenter.compose.ComposeStatus
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertIs
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+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)
+ }
+
+ @Test
+ fun composeExposesOnlySupportedVisibilityChoices() {
+ val dataSource = dataSource()
+ val visibility = assertNotNull(dataSource.composeConfig(ComposeType.New).visibility)
+
+ assertEquals(
+ listOf(
+ UiTimelineV2.Post.Visibility.Public,
+ UiTimelineV2.Post.Visibility.Private,
+ ),
+ visibility.allVisibilities,
+ )
+ assertEquals(UiTimelineV2.Post.Visibility.Public, visibility.defaultVisibility)
+ }
+
+ @Test
+ fun composeVisibilityMapsToTumblrPostState() {
+ assertEquals(
+ "published",
+ UiTimelineV2.Post.Visibility.Public
+ .toTumblrState(),
+ )
+ assertEquals(
+ "private",
+ UiTimelineV2.Post.Visibility.Private
+ .toTumblrState(),
+ )
+ assertFailsWith {
+ UiTimelineV2.Post.Visibility.Home
+ .toTumblrState()
+ }
+ }
+
+ @Test
+ fun replyComposeIsRejectedInsteadOfCreatingReblog() =
+ runTest {
+ val dataSource = dataSource()
+
+ assertFailsWith {
+ dataSource.compose(
+ data =
+ ComposeData(
+ content = "This must not become a reblog",
+ referenceStatus =
+ ComposeData.ReferenceStatus(
+ ComposeStatus.Reply(tumblrPostKey("staff", "123")),
+ ),
+ ),
+ progress = {},
+ )
+ }
+ }
+
+ private fun dataSource(): TumblrDataSource =
+ TumblrDataSource(
+ accountKey = accountKey,
+ credentialFlow = flowOf(credential()),
+ updateCredential = {},
+ )
+
+ private fun assertFullWidthPost(candidate: TimelineCandidate<*>) {
+ val patch = assertNotNull(candidate.appearancePatch)
+
+ 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 000000000..934e8492a
--- /dev/null
+++ b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrKeysTest.kt
@@ -0,0 +1,51 @@
+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"),
+ )
+ assertEquals(
+ MicroBlogKey(id = "staff", host = TUMBLR_HOST),
+ tumblrUserKey("https://www.tumblr.com/staff"),
+ )
+ assertEquals(
+ MicroBlogKey(id = "staff", host = TUMBLR_HOST),
+ tumblrUserKey("https://www.tumblr.com/@Staff"),
+ )
+ assertEquals(
+ MicroBlogKey(id = "www.davidslog.com", host = TUMBLR_HOST),
+ tumblrUserKey("https://www.davidslog.com/archive"),
+ )
+ }
+
+ @Test
+ 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/TumblrLoadersTest.kt b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrLoadersTest.kt
new file mode 100644
index 000000000..7b9c7a3fe
--- /dev/null
+++ b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrLoadersTest.kt
@@ -0,0 +1,158 @@
+package dev.dimension.flare.data.datasource.tumblr
+
+import de.jensklingenberg.ktorfit.converter.ResponseConverterFactory
+import de.jensklingenberg.ktorfit.ktorfit
+import dev.dimension.flare.common.JSON
+import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest
+import dev.dimension.flare.data.network.nullableFallbackJson
+import dev.dimension.flare.data.network.tumblr.TumblrAuthResources
+import dev.dimension.flare.data.network.tumblr.TumblrResources
+import dev.dimension.flare.data.network.tumblr.TumblrService
+import dev.dimension.flare.data.network.tumblr.TumblrTokenResponse
+import dev.dimension.flare.data.network.tumblr.createTumblrResources
+import dev.dimension.flare.data.platform.TumblrCredential
+import dev.dimension.flare.model.MicroBlogKey
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.respond
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.http.ContentType
+import io.ktor.http.Headers
+import io.ktor.http.HttpHeaders
+import io.ktor.http.HttpStatusCode
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotEquals
+import kotlin.test.assertTrue
+
+class TumblrLoadersTest {
+ @Test
+ fun postsAndMediaTimelinesUseDifferentPagingKeys() {
+ val service = serviceReturning("""{"meta":{"status":200},"response":{"posts":[]}}""")
+
+ val posts = TumblrBlogTimelineLoader(service, accountKey, blogKey, mediaOnly = false)
+ val media = TumblrBlogTimelineLoader(service, accountKey, blogKey, mediaOnly = true)
+
+ assertNotEquals(posts.pagingKey, media.pagingKey)
+ }
+
+ @Test
+ fun mediaTimelineKeepsNextPageWhenUnfilteredResponseIsFull() =
+ runTest {
+ val service = serviceReturning(fullPostsPageJson())
+ val loader = TumblrBlogTimelineLoader(service, accountKey, blogKey, mediaOnly = true)
+
+ val result = loader.load(pageSize = 20, request = PagingRequest.Refresh)
+
+ assertEquals(1, result.data.size)
+ assertEquals("20", result.nextKey)
+ }
+
+ @Test
+ fun followingPagingKeyIncludesRequestedBlog() {
+ val service = serviceReturning("""{"meta":{"status":200},"response":{"blogs":[]}}""")
+ val staff = TumblrFollowingLoader(service, accountKey, tumblrUserKey("staff"))
+ val engineering = TumblrFollowingLoader(service, accountKey, tumblrUserKey("engineering"))
+
+ assertNotEquals(staff.pagingKey, engineering.pagingKey)
+ assertTrue(staff.pagingKey.contains("staff"))
+ }
+
+ @Test
+ fun relationUsesFollowedField() =
+ runTest {
+ val service =
+ serviceReturning(
+ """
+ {
+ "meta": {"status": 200, "msg": "OK"},
+ "response": {
+ "blog": {
+ "name": "staff",
+ "following": false,
+ "followed": true
+ }
+ }
+ }
+ """.trimIndent(),
+ )
+ val loader = TumblrLoader(service, accountKey)
+
+ val relation = loader.relation(blogKey)
+
+ assertTrue(relation.following)
+ assertFalse(relation.blocking)
+ }
+
+ private fun serviceReturning(responseJson: String): TumblrService {
+ val client =
+ HttpClient(
+ MockEngine {
+ respond(
+ content = responseJson,
+ status = HttpStatusCode.OK,
+ headers = Headers.build { append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) },
+ )
+ },
+ ) {
+ install(ContentNegotiation) {
+ nullableFallbackJson(JSON)
+ }
+ }
+ val resources =
+ ktorfit {
+ baseUrl("https://api.tumblr.com/v2/")
+ httpClient(client)
+ converterFactories(ResponseConverterFactory())
+ }.createTumblrResources()
+ return TumblrService(
+ credentialFlow = MutableStateFlow(credential),
+ authResources = unusedAuthResources,
+ resources = resources,
+ )
+ }
+
+ private fun fullPostsPageJson(): String {
+ val posts =
+ (1..20).joinToString(separator = ",") { id ->
+ if (id == 1) {
+ """{"id_string":"1","blog_name":"staff","content":[{"type":"image","media":[{"url":"https://64.media.tumblr.com/1.jpg","width":800,"height":600}]}]}"""
+ } else {
+ """{"id_string":"$id","blog_name":"staff","content":[{"type":"text","text":"post $id"}]}"""
+ }
+ }
+ return """{"meta":{"status":200,"msg":"OK"},"response":{"posts":[$posts]}}"""
+ }
+
+ private companion object {
+ val accountKey = MicroBlogKey(id = "me", host = "tumblr.com")
+ val blogKey = tumblrUserKey("staff")
+ val credential =
+ TumblrCredential(
+ accessToken = "token",
+ blogIdentifier = "me",
+ blogName = "me",
+ blogUrl = "https://me.tumblr.com/",
+ )
+ val unusedAuthResources =
+ object : TumblrAuthResources {
+ override suspend fun requestToken(
+ grantType: String,
+ clientId: String,
+ clientSecret: String,
+ redirectUri: String,
+ code: String,
+ ): TumblrTokenResponse = error("Not used")
+
+ override suspend fun refreshToken(
+ grantType: String,
+ clientId: String,
+ clientSecret: String,
+ refreshToken: String,
+ ): TumblrTokenResponse = error("Not used")
+ }
+ }
+}
diff --git a/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrMapperTest.kt b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrMapperTest.kt
new file mode 100644
index 000000000..d418ea8d8
--- /dev/null
+++ b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrMapperTest.kt
@@ -0,0 +1,929 @@
+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.TumblrNpfAttribution
+import dev.dimension.flare.data.network.tumblr.TumblrNpfBlock
+import dev.dimension.flare.data.network.tumblr.TumblrNpfFormatting
+import dev.dimension.flare.data.network.tumblr.TumblrNpfLayout
+import dev.dimension.flare.data.network.tumblr.TumblrNpfLayoutDisplay
+import dev.dimension.flare.data.network.tumblr.TumblrNpfMedia
+import dev.dimension.flare.data.network.tumblr.TumblrPost
+import dev.dimension.flare.data.network.tumblr.TumblrTrailItem
+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(3, timelinePost.actions.size)
+ }
+
+ @Test
+ fun actionMenusExposeSupportedRepostQuoteLikeAndMoreActions() {
+ 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,
+ replyCount = 2,
+ reblogCount = 3,
+ likeCount = 5,
+ 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(3, actions.size)
+
+ val repostGroup = assertIs(actions[0])
+ 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[1])
+ assertEquals(PostActionFamily.Like, like.actionFamily)
+ assertEquals(5L, like.count?.value)
+
+ val more = assertIs(actions[2])
+ val overflowFamilies =
+ more.actions
+ .filterIsInstance()
+ .mapNotNull { it.actionFamily }
+ assertEquals(
+ listOf(
+ PostActionFamily.Comment,
+ 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)
+ }
+
+ @Test
+ fun externalAudioPrefersPlayableMediaUrl() {
+ val post =
+ TumblrPost(
+ idString = "audio-external",
+ blogName = "staff",
+ content =
+ listOf(
+ TumblrNpfBlock(
+ type = "audio",
+ provider = "soundcloud",
+ title = "Track",
+ url = "https://soundcloud.com/example/track",
+ media = listOf(TumblrNpfMedia(type = "audio/mp3", url = "https://cdn.example.com/track.mp3")),
+ ),
+ ),
+ )
+
+ val timelinePost = assertIs(post.toUiTimeline(accountKey)).post
+ assertEquals("https://cdn.example.com/track.mp3", assertIs(timelinePost.images.single()).url)
+ }
+
+ @Test
+ fun formattingOffsetsUseUnicodeCodePoints() {
+ val post =
+ TumblrPost(
+ idString = "unicode-formatting",
+ blogName = "staff",
+ content =
+ listOf(
+ textBlock(
+ text = "😀a link",
+ formatting =
+ listOf(
+ TumblrNpfFormatting(type = "link", start = 3, end = 7, url = "https://example.com"),
+ ),
+ ),
+ ),
+ )
+
+ val content = assertIs(post.toUiTimeline(accountKey)).post.content
+ val runs = assertIs(content.renderRuns.single()).runs.filterIsInstance()
+ assertEquals("😀a ", runs[0].text)
+ assertEquals("link", runs[1].text)
+ assertEquals("https://example.com", runs[1].style.link)
+ }
+
+ @Test
+ fun completeReblogTrailIsPreservedInOrderWithRichText() {
+ val post =
+ TumblrPost(
+ idString = "trail-chain",
+ blogName = "me",
+ content = emptyList(),
+ trail =
+ listOf(
+ TumblrTrailItem(
+ blog = TumblrBlog(name = "root"),
+ post = TumblrTrailPost(id = "root-id"),
+ content =
+ listOf(
+ textBlock(
+ "Root text",
+ formatting = listOf(TumblrNpfFormatting(type = "bold", start = 0, end = 4)),
+ ),
+ ),
+ ),
+ TumblrTrailItem(
+ blog = TumblrBlog(name = "parent"),
+ post = TumblrTrailPost(id = "parent-id"),
+ content = listOf(textBlock("Parent comment")),
+ ),
+ ),
+ )
+
+ val rendered = assertIs(post.toUiTimeline(accountKey))
+ assertEquals("Parent comment", assertNotNull(rendered.presentation.repost).content.raw)
+ assertEquals(
+ "Root text",
+ rendered.presentation.inlineParents
+ .single()
+ .content.raw,
+ )
+ val rootRun =
+ assertIs(
+ rendered.presentation.inlineParents
+ .single()
+ .content.renderRuns
+ .single(),
+ ).runs
+ .filterIsInstance()
+ .first()
+ assertTrue(rootRun.style.bold)
+ }
+
+ @Test
+ fun brokenTrailAndRowsVisibilityArePreserved() {
+ val post =
+ TumblrPost(
+ idString = "broken-trail",
+ blogName = "me",
+ content = listOf(textBlock("My comment")),
+ trail =
+ listOf(
+ TumblrTrailItem(
+ brokenBlogName = "gone-blog",
+ content = listOf(textBlock("First"), textBlock("Hidden"), textBlock("Third")),
+ layout =
+ listOf(
+ TumblrNpfLayout(
+ type = "rows",
+ display = listOf(TumblrNpfLayoutDisplay(blocks = listOf(2, 0))),
+ ),
+ ),
+ ),
+ ),
+ )
+
+ val quote = assertIs(post.toUiTimeline(accountKey)).presentation.quotes.single()
+ assertEquals("gone-blog", quote.user?.handle?.raw)
+ assertEquals("Third\nFirst", quote.content.raw)
+ assertIs(quote.clickEvent)
+ }
+
+ @Test
+ fun imageCaptionAskGroupingAndHtmlDescriptionAreMapped() {
+ val post =
+ TumblrPost(
+ idString = "ask-caption",
+ blogName = "staff",
+ content =
+ listOf(
+ imageBlock(
+ url = "https://64.media.tumblr.com/ask.jpg",
+ width = 640,
+ height = 480,
+ altText = "ask",
+ ).copy(caption = "Image caption"),
+ textBlock("Question"),
+ textBlock("Answer"),
+ ),
+ layout =
+ listOf(
+ TumblrNpfLayout(
+ type = "ask",
+ blocks = listOf(1),
+ attribution = TumblrNpfAttribution(type = "blog", blog = TumblrBlog(name = "asker")),
+ ),
+ ),
+ )
+
+ val timelinePost = assertIs(post.toUiTimeline(accountKey)).post
+ assertEquals("Image caption\nQuestion\nAnswer", timelinePost.content.raw)
+ assertTrue(
+ timelinePost.content.renderRuns
+ .filterIsInstance()
+ .any { it.block.isBlockQuote },
+ )
+
+ val profile = TumblrBlog(name = "staff", description = "Hello Tumblr & friends
").toUiProfile(accountKey)
+ assertEquals("Hello Tumblr & friends", profile.description?.raw)
+ }
+
+ @Test
+ fun missingBlogNameIsResolvedFromTumblrSubdomainUrl() {
+ val profile = TumblrBlog(url = "https://staff.tumblr.com/").toUiProfile(accountKey)
+
+ assertEquals(tumblrUserKey("staff"), profile.key)
+ }
+
+ @Test
+ fun noteMetadataIsNotUsedAsAnActionTotal() {
+ val post =
+ TumblrPost(
+ idString = "notes",
+ blogName = "staff",
+ reblogKey = "key",
+ noteCount = 42,
+ notes = listOf(TumblrNote(type = "like")),
+ )
+
+ val actions = assertIs(post.toUiTimeline(accountKey)).post.actions
+ assertEquals(0, assertIs(assertIs(actions[0]).displayItem).count?.value)
+ assertEquals(0, assertIs(actions[1]).count?.value)
+ val overflow = assertIs(actions[2]).actions.filterIsInstance()
+ val notes = overflow.single { it.actionFamily == PostActionFamily.Comment }
+ assertEquals(42L, notes.count?.value)
+ }
+
+ @Test
+ fun privatePostUsesPrivateVisibility() {
+ val post =
+ TumblrPost(
+ idString = "private-post",
+ blogName = "staff",
+ state = "private",
+ content = listOf(textBlock("Private content")),
+ )
+
+ val timelinePost = assertIs(post.toUiTimeline(accountKey)).post
+
+ assertEquals(UiTimelineV2.Post.Visibility.Private, timelinePost.visibility)
+ }
+
+ private fun textBlock(
+ text: String,
+ formatting: List = emptyList(),
+ 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/TumblrPlatformDetectorTest.kt b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/network/tumblr/TumblrPlatformDetectorTest.kt
new file mode 100644
index 000000000..7c907e9ac
--- /dev/null
+++ b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/network/tumblr/TumblrPlatformDetectorTest.kt
@@ -0,0 +1,29 @@
+package dev.dimension.flare.data.network.tumblr
+
+import dev.dimension.flare.data.platform.TUMBLR_HOST
+import dev.dimension.flare.model.PlatformType
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class TumblrPlatformDetectorTest {
+ @Test
+ fun detectsRootAndBlogHostsAsTumblr() =
+ runTest {
+ listOf(TUMBLR_HOST, "www.tumblr.com", "Staff.tumblr.com").forEach { host ->
+ val result = TumblrPlatformDetector.detect(host)
+
+ assertEquals(TUMBLR_HOST, result?.host)
+ assertEquals(PlatformType.Tumblr, result?.platformType)
+ }
+ }
+
+ @Test
+ fun rejectsLookalikeAndNestedHosts() =
+ runTest {
+ assertNull(TumblrPlatformDetector.detect("tumblr.com.example.org"))
+ assertNull(TumblrPlatformDetector.detect("not-tumblr.com"))
+ assertNull(TumblrPlatformDetector.detect("foo.bar.tumblr.com"))
+ }
+}
diff --git a/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/network/tumblr/TumblrServiceTest.kt b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/network/tumblr/TumblrServiceTest.kt
new file mode 100644
index 000000000..3b7ca9bbe
--- /dev/null
+++ b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/network/tumblr/TumblrServiceTest.kt
@@ -0,0 +1,400 @@
+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 kotlinx.serialization.decodeFromString
+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")),
+ poster = listOf(TumblrNpfMedia(url = "https://example.com/poster.jpg", type = "image/jpeg")),
+ ),
+ ),
+ ),
+ )
+
+ assertTrue("\"media\":[{\"identifier\":\"image0\",\"type\":\"image/jpeg\"}]" in image)
+ assertTrue("\"media\":{\"identifier\":\"video0\",\"type\":\"video/mp4\"}" in video)
+ assertTrue("\"poster\":[{\"type\":\"image/jpeg\",\"url\":\"https://example.com/poster.jpg\"}]" in video)
+ }
+
+ @Test
+ fun blogInfoDecodesFollowedAndUuidOnlyBlog() {
+ val followed = JSON.decodeFromString("""{"name":"staff","followed":true}""")
+ val uuidOnly = JSON.decodeFromString("""{"uuid":"t:abc123"}""")
+
+ assertEquals(true, followed.followed)
+ assertEquals(null, uuidOnly.name)
+ assertEquals("t:abc123", uuidOnly.uuid)
+ }
+
+ @Test
+ 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 followingUsesBlogScopedEndpoint() =
+ runTest {
+ val requests = mutableListOf()
+ val service =
+ TumblrService(
+ credentialFlow = MutableStateFlow(credential()),
+ authResources = unusedAuthResources,
+ resources =
+ mockResources { scope, request ->
+ requests += request
+ scope.respondJson(
+ """{"meta":{"status":200,"msg":"OK"},"response":{"blogs":[]}}""",
+ )
+ },
+ )
+
+ service.following(
+ blogIdentifier = "mtlaster",
+ limit = 20,
+ offset = 40,
+ )
+
+ val request = requests.single()
+ assertEquals(HttpMethod.Get, request.method)
+ assertEquals("/v2/blog/mtlaster/following", request.url.encodedPath)
+ assertEquals("20", request.url.parameters["limit"])
+ assertEquals("40", request.url.parameters["offset"])
+ }
+
+ @Test
+ fun createPostDecodesNpfPostMutationResponse() =
+ runTest {
+ 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
+}
diff --git a/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/platform/TumblrPlatformSpecTest.kt b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/platform/TumblrPlatformSpecTest.kt
new file mode 100644
index 000000000..65f0b5f56
--- /dev/null
+++ b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/data/platform/TumblrPlatformSpecTest.kt
@@ -0,0 +1,27 @@
+package dev.dimension.flare.data.platform
+
+import dev.dimension.flare.model.MicroBlogKey
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class TumblrPlatformSpecTest {
+ @Test
+ fun deepLinksIncludePostUrlsWithAndWithoutSlugs() {
+ val patterns =
+ TumblrPlatformSpec
+ .deepLinks(MicroBlogKey(id = "staff", host = TUMBLR_HOST))
+ .map { it.uriPattern }
+
+ assertEquals(
+ listOf(
+ "https://www.tumblr.com/{blogName}/{id}",
+ "https://www.tumblr.com/{blogName}/{id}/{slug}",
+ "https://{blogname}.tumblr.com/post/{id}",
+ "https://{blogname}.tumblr.com/post/{id}/{slug}",
+ "https://www.tumblr.com/{blogName}",
+ "https://{blogname}.tumblr.com",
+ ),
+ patterns,
+ )
+ }
+}
diff --git a/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/login/TumblrLoginProviderTest.kt b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/login/TumblrLoginProviderTest.kt
new file mode 100644
index 000000000..6a16e4cba
--- /dev/null
+++ b/social/tumblr/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/login/TumblrLoginProviderTest.kt
@@ -0,0 +1,32 @@
+package dev.dimension.flare.ui.presenter.login
+
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class TumblrLoginProviderTest {
+ @Test
+ fun oauthErrorCallbackCanResumeAndClearsLoading() =
+ runTest {
+ val handler =
+ TumblrLoginProvider.createHandler(
+ LoginContext(
+ host = "tumblr.com",
+ methodType = LoginMethodType.OAuth,
+ onSuccess = {},
+ ),
+ )
+ val callback =
+ "https://flareapp.moe/tumblr-callback.html" +
+ "?error=access_denied&error_description=User%20denied%20access&state=test-state"
+
+ assertTrue(handler.canResume(callback))
+
+ handler.resume(callback)
+
+ assertFalse(handler.state.value.loading)
+ assertEquals("User denied access", handler.state.value.error)
+ }
+}