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 ce2c502a3..e3e4c6923 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 @@ -7,6 +7,7 @@ import compose.icons.fontawesomeicons.Regular import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.brands.Bluesky import compose.icons.fontawesomeicons.brands.Mastodon +import compose.icons.fontawesomeicons.brands.Tumblr import compose.icons.fontawesomeicons.brands.Weibo import compose.icons.fontawesomeicons.brands.XTwitter import compose.icons.fontawesomeicons.regular.Bookmark @@ -95,6 +96,7 @@ public fun UiIcon.toImageVector(): ImageVector = UiIcon.Mastodon -> FontAwesomeIcons.Brands.Mastodon UiIcon.Misskey -> FontAwesomeIcons.Brands.Misskey UiIcon.Bluesky -> FontAwesomeIcons.Brands.Bluesky + UiIcon.Tumblr -> FontAwesomeIcons.Brands.Tumblr UiIcon.Nostr -> FontAwesomeIcons.Brands.Nostr UiIcon.Twitter -> FontAwesomeIcons.Brands.XTwitter UiIcon.X -> FontAwesomeIcons.Brands.XTwitter diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model/PlatformTypeIcon.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model/PlatformTypeIcon.kt index fd185f669..189133f0c 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 @@ -5,6 +5,7 @@ import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Brands import compose.icons.fontawesomeicons.brands.Bluesky import compose.icons.fontawesomeicons.brands.Mastodon +import compose.icons.fontawesomeicons.brands.Tumblr import compose.icons.fontawesomeicons.brands.Weibo import compose.icons.fontawesomeicons.brands.XTwitter import dev.dimension.flare.model.PlatformType @@ -18,6 +19,7 @@ public val PlatformType.brandIcon: ImageVector PlatformType.Mastodon -> FontAwesomeIcons.Brands.Mastodon PlatformType.Misskey -> FontAwesomeIcons.Brands.Misskey PlatformType.Bluesky -> FontAwesomeIcons.Brands.Bluesky + PlatformType.Tumblr -> FontAwesomeIcons.Brands.Tumblr PlatformType.xQt -> FontAwesomeIcons.Brands.XTwitter PlatformType.VVo -> FontAwesomeIcons.Brands.Weibo } diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/ServiceSelectionScreenContent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/ServiceSelectionScreenContent.kt index b5761c88d..07183d2fb 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/ServiceSelectionScreenContent.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/ServiceSelectionScreenContent.kt @@ -449,6 +449,38 @@ public fun ServiceSelectionScreenContent( } } + PlatformType.Tumblr -> { + registerDeeplinkCallback { + state.tumblrLoginState.resume(it) + } + state.tumblrLoginState.resumedState + ?.onLoading { + PlatformText( + text = stringResource(Res.string.mastodon_login_verify_message), + ) + PlatformLinearProgressIndicator() + }?.onError { + PlatformText(text = it.message ?: "Unknown error") + } ?: run { + PlatformFilledTonalButton( + onClick = { + state.tumblrLoginState.login( + launchUrl = openUri, + ) + }, + modifier = Modifier.width(300.dp), + enabled = !state.tumblrLoginState.loading, + ) { + PlatformText( + text = stringResource(Res.string.service_select_next_button), + ) + } + state.tumblrLoginState.error?.let { + PlatformText(text = it) + } + } + } + PlatformType.Misskey -> { registerDeeplinkCallback { state.misskeyLoginState.resume(it) 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 cacbee1fe..bb3b2e374 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 @@ -13,6 +13,8 @@ import dev.dimension.flare.ui.model.mapper.misskeyReact import dev.dimension.flare.ui.model.mapper.misskeyRenote import dev.dimension.flare.ui.model.mapper.nostrLike import dev.dimension.flare.ui.model.mapper.nostrRepost +import dev.dimension.flare.ui.model.mapper.tumblrLike +import dev.dimension.flare.ui.model.mapper.tumblrReblog import dev.dimension.flare.ui.model.mapper.vvoFavorite import dev.dimension.flare.ui.model.mapper.vvoLike import dev.dimension.flare.ui.model.mapper.vvoLikeComment @@ -429,6 +431,38 @@ internal sealed interface PostEvent { val accountKey: MicroBlogKey, ) : Nostr } + + @Serializable + sealed interface Tumblr : PostEvent { + @Serializable + data class Like( + override val postKey: MicroBlogKey, + val liked: Boolean, + val accountKey: MicroBlogKey, + ) : Tumblr, + UpdatePostActionMenuEvent { + override fun nextActionMenu(): ActionMenu.Item = + ActionMenu.tumblrLike( + statusKey = postKey, + liked = !liked, + accountKey = accountKey, + ) + } + + @Serializable + data class Reblog( + override val postKey: MicroBlogKey, + val canReblog: Boolean, + val accountKey: MicroBlogKey, + ) : Tumblr { + fun nextActionMenu(): ActionMenu.Item = + ActionMenu.tumblrReblog( + statusKey = postKey, + canReblog = false, + accountKey = accountKey, + ) + } + } } internal interface UpdatePostActionMenuEvent : PostEvent { diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrDataSource.kt new file mode 100644 index 000000000..e9c9d9f60 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/tumblr/TumblrDataSource.kt @@ -0,0 +1,285 @@ +package dev.dimension.flare.data.datasource.tumblr + +import dev.dimension.flare.data.datasource.microblog.ActionMenu +import dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataSource +import dev.dimension.flare.data.datasource.microblog.ComposeConfig +import dev.dimension.flare.data.datasource.microblog.ComposeData +import dev.dimension.flare.data.datasource.microblog.ComposeType +import dev.dimension.flare.data.datasource.microblog.DatabaseUpdater +import dev.dimension.flare.data.datasource.microblog.NotificationFilter +import dev.dimension.flare.data.datasource.microblog.PostEvent +import dev.dimension.flare.data.datasource.microblog.ProfileTab +import dev.dimension.flare.data.datasource.microblog.datasource.PostDataSource +import dev.dimension.flare.data.datasource.microblog.handler.PostEventHandler +import dev.dimension.flare.data.datasource.microblog.handler.PostHandler +import dev.dimension.flare.data.datasource.microblog.loader.PostLoader +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader +import dev.dimension.flare.data.datasource.microblog.paging.notSupported +import dev.dimension.flare.data.network.tumblr.TumblrService +import dev.dimension.flare.data.repository.AccountRepository +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiAccount +import dev.dimension.flare.ui.model.UiHashtag +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.toUi +import dev.dimension.flare.ui.model.mapper.tumblrReblog +import dev.dimension.flare.ui.presenter.compose.ComposeStatus +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.first +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +internal class TumblrDataSource( + override val accountKey: MicroBlogKey, +) : AuthenticatedMicroblogDataSource, + PostDataSource, + PostEventHandler.Handler, + KoinComponent { + private val accountRepository: AccountRepository by inject() + + private suspend fun credential(): UiAccount.Tumblr.Credential = + accountRepository.credentialFlow(accountKey).first() + + private suspend fun service(): TumblrService { + val credential = credential() + return TumblrService( + consumerKey = credential.consumerKey, + accessToken = credential.accessToken, + ) + } + + private val postLoader by lazy { + object : PostLoader { + override suspend fun status(statusKey: MicroBlogKey): UiTimelineV2 = + service() + .post( + blogIdentifier = statusKey.host.toTumblrBlogIdentifier(), + postId = statusKey.id, + ).toUi(AccountType.Specific(accountKey)) + + override suspend fun deleteStatus(statusKey: MicroBlogKey) { + service().deletePost( + blogIdentifier = credential().blogIdentifier, + postId = statusKey.id, + ) + } + } + } + + override val postHandler by lazy { + PostHandler( + accountType = AccountType.Specific(accountKey), + loader = postLoader, + ) + } + + override val postEventHandler by lazy { + PostEventHandler( + accountType = AccountType.Specific(accountKey), + handler = this, + ) + } + + override fun homeTimeline(): RemoteLoader = + object : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + val offset = request.offset() + val response = service().dashboard(offset = offset, limit = pageSize.coerceIn(1, 20)) + val data = + response.posts.map { + it.toUi(AccountType.Specific(accountKey)) + } + val nextOffset = offset + data.size + return PagingResult( + endOfPaginationReached = data.size < pageSize.coerceIn(1, 20), + data = data, + nextKey = nextOffset.toString(), + ) + } + } + + override fun userTimeline( + userKey: MicroBlogKey, + mediaOnly: Boolean, + ): RemoteLoader = + object : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + val offset = request.offset() + val response = + service().blogPosts( + blogIdentifier = userKey.id.toTumblrBlogIdentifier(), + offset = offset, + limit = pageSize.coerceIn(1, 20), + ) + val data = + response.posts + .map { it.toUi(AccountType.Specific(accountKey)) } + .filterNot { mediaOnly && it.images.isEmpty() } + val nextOffset = offset + response.posts.size + return PagingResult( + endOfPaginationReached = response.posts.size < pageSize.coerceIn(1, 20), + data = data, + nextKey = nextOffset.toString(), + ) + } + } + + override fun context(statusKey: MicroBlogKey): RemoteLoader = + object : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + val post = + service() + .post( + blogIdentifier = statusKey.host.toTumblrBlogIdentifier(), + postId = statusKey.id, + ).toUi(AccountType.Specific(accountKey)) + return PagingResult(endOfPaginationReached = true, data = listOf(post)) + } + } + + override fun searchStatus(query: String): RemoteLoader = notSupported() + + override fun searchUser(query: String): RemoteLoader = notSupported() + + override fun discoverUsers(): RemoteLoader = notSupported() + + override fun discoverStatuses(): RemoteLoader = notSupported() + + override fun discoverHashtags(): RemoteLoader = notSupported() + + override fun following(userKey: MicroBlogKey): RemoteLoader = notSupported() + + override fun fans(userKey: MicroBlogKey): RemoteLoader = notSupported() + + override fun profileTabs(userKey: MicroBlogKey): ImmutableList = + persistentListOf( + ProfileTab.Timeline( + type = ProfileTab.Timeline.Type.Status, + loader = userTimeline(userKey, mediaOnly = false), + ), + ProfileTab.Media, + ) + + override fun notification(type: NotificationFilter): RemoteLoader = notSupported() + + override val supportedNotificationFilter: List = emptyList() + + override suspend fun compose( + data: ComposeData, + progress: () -> Unit, + ) { + require(data.medias.isEmpty()) { + "Tumblr media upload is not implemented yet" + } + require(data.poll == null) { + "Tumblr polls are not implemented yet" + } + when (val reference = data.referenceStatus?.composeStatus) { + is ComposeStatus.Quote -> { + val sourcePost = + service().post( + blogIdentifier = reference.statusKey.host.toTumblrBlogIdentifier(), + postId = reference.statusKey.id, + ) + val reblogKey = requireNotNull(sourcePost.reblogKey) { "Tumblr post cannot be reblogged" } + service().reblogPost( + blogIdentifier = credential().blogIdentifier, + postId = reference.statusKey.id, + reblogKey = reblogKey, + comment = data.content.takeIf { it.isNotBlank() }, + ) + } + + else -> { + service().createTextPost( + blogIdentifier = credential().blogIdentifier, + content = data.content, + ) + } + } + } + + override fun composeConfig(type: ComposeType): ComposeConfig = + ComposeConfig( + text = ComposeConfig.Text(maxLength = 4096), + ) + + override suspend fun handle( + event: PostEvent, + updater: DatabaseUpdater, + ) { + require(event is PostEvent.Tumblr) + when (event) { + is PostEvent.Tumblr.Like -> like(event) + is PostEvent.Tumblr.Reblog -> reblog(event, updater) + } + } + + private suspend fun like(event: PostEvent.Tumblr.Like) { + val sourcePost = + service().post( + blogIdentifier = event.postKey.host.toTumblrBlogIdentifier(), + postId = event.postKey.id, + ) + val reblogKey = requireNotNull(sourcePost.reblogKey) { "Tumblr post cannot be liked" } + if (event.liked) { + service().unlike(event.postKey.id, reblogKey) + } else { + service().like(event.postKey.id, reblogKey) + } + } + + private suspend fun reblog( + event: PostEvent.Tumblr.Reblog, + updater: DatabaseUpdater, + ) { + require(event.canReblog) { "Tumblr post cannot be reblogged again" } + val sourcePost = + service().post( + blogIdentifier = event.postKey.host.toTumblrBlogIdentifier(), + postId = event.postKey.id, + ) + val reblogKey = requireNotNull(sourcePost.reblogKey) { "Tumblr post cannot be reblogged" } + service().reblogPost( + blogIdentifier = credential().blogIdentifier, + postId = event.postKey.id, + reblogKey = reblogKey, + ) + updater.updateActionMenu( + event.postKey, + newActionMenu = + ActionMenu.tumblrReblog( + statusKey = event.postKey, + canReblog = false, + accountKey = event.accountKey, + ), + ) + } +} + +private fun PagingRequest.offset(): Int = + when (this) { + PagingRequest.Refresh -> 0 + is PagingRequest.Append -> nextKey.toIntOrNull() ?: 0 + is PagingRequest.Prepend -> previousKey.toIntOrNull() ?: 0 + } + +private fun String.toTumblrBlogIdentifier(): String = + when { + contains('.') -> this + else -> "$this.tumblr.com" + } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AiPromptDefaults.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AiPromptDefaults.kt index 2b6e3f4c5..365302eaa 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AiPromptDefaults.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AiPromptDefaults.kt @@ -10,7 +10,8 @@ internal object AiPromptDefaults { "Inline markers like {{T0}} and {{L1}} are control markers.\n" + "Keep every control marker exactly unchanged and in the same order.\n" + "Translate every natural-language segment that appears after a {{Tn}} marker into natural {target_language}.\n" + - "Copying the original source text after a {{Tn}} marker is wrong unless that segment is already naturally written in {target_language}.\n" + + "Copying the original source text after a {{Tn}} marker is wrong unless that segment " + + "is already naturally written in {target_language}.\n" + "If you are unsure, still provide your best translation in {target_language} instead of leaving the source text unchanged.\n" + "Do not add any text after a {{Ln}} marker.\n" + "For item headers, use S only when the source text is already in {target_language}; otherwise keep C and translate.\n" + diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrOAuth2Config.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrOAuth2Config.kt new file mode 100644 index 000000000..421c23ae9 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrOAuth2Config.kt @@ -0,0 +1,61 @@ +package dev.dimension.flare.data.network.tumblr + +import dev.dimension.flare.data.network.tumblr.model.TumblrApplicationCredential +import dev.dimension.flare.ui.route.DeeplinkRoute + +internal object TumblrOAuth2Config { + private const val CLIENT_ID: String = "" + private const val CLIENT_SECRET: String = "" + internal const val HOST: String = "tumblr.com" + internal val REDIRECT_URI: String = DeeplinkRoute.Companion.Callback.TUMBLR + private const val DEFAULT_SCOPE: String = "basic write offline_access" + + fun applicationCredential(state: String? = null): TumblrApplicationCredential { + require(CLIENT_ID.isNotBlank() && CLIENT_SECRET.isNotBlank()) { + "Tumblr OAuth2 is not configured. Please set CLIENT_ID and CLIENT_SECRET in TumblrOAuth2Config." + } + return TumblrApplicationCredential( + consumerKey = CLIENT_ID, + consumerSecret = CLIENT_SECRET, + authState = state, + ) + } + + fun buildAuthorizeUrl( + state: String, + scope: String = DEFAULT_SCOPE, + ): String = + "https://www.tumblr.com/oauth2/authorize?" + + "response_type=code" + + "&client_id=${encode(CLIENT_ID)}" + + "&redirect_uri=${encode(REDIRECT_URI)}" + + "&scope=${encode(scope)}" + + "&state=${encode(state)}" + + private fun encode(value: String): String = + buildString { + value.encodeToByteArray().forEach { byte -> + val c = byte.toInt().toChar() + if ( + c in 'A'..'Z' || + c in 'a'..'z' || + c in '0'..'9' || + c == '-' || + c == '.' || + c == '_' || + c == '~' + ) { + append(c) + } else { + append('%') + append( + byte + .toUByte() + .toString(16) + .uppercase() + .padStart(2, '0'), + ) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrOAuth2Service.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrOAuth2Service.kt new file mode 100644 index 000000000..222f20583 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrOAuth2Service.kt @@ -0,0 +1,45 @@ +package dev.dimension.flare.data.network.tumblr + +import dev.dimension.flare.data.network.ktorClient +import dev.dimension.flare.data.network.tumblr.model.TumblrOAuth2TokenResponse +import io.ktor.client.call.body +import io.ktor.client.request.forms.submitForm +import io.ktor.http.Parameters + +internal class TumblrOAuth2Service { + suspend fun requestToken( + code: String, + clientId: String, + clientSecret: String, + redirectUri: String? = null, + ): TumblrOAuth2TokenResponse = + ktorClient() + .submitForm( + url = "https://api.tumblr.com/v2/oauth2/token", + formParameters = + Parameters.build { + append("grant_type", "authorization_code") + append("code", code) + append("client_id", clientId) + append("client_secret", clientSecret) + redirectUri?.let { append("redirect_uri", it) } + }, + ).body() + + suspend fun refreshToken( + refreshToken: String, + clientId: String, + clientSecret: String, + ): TumblrOAuth2TokenResponse = + ktorClient() + .submitForm( + url = "https://api.tumblr.com/v2/oauth2/token", + formParameters = + Parameters.build { + append("grant_type", "refresh_token") + append("refresh_token", refreshToken) + append("client_id", clientId) + append("client_secret", clientSecret) + }, + ).body() +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrPlatformDetector.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrPlatformDetector.kt new file mode 100644 index 000000000..190b80e73 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrPlatformDetector.kt @@ -0,0 +1,23 @@ +package dev.dimension.flare.data.network.tumblr + +import dev.dimension.flare.data.network.nodeinfo.NodeData +import dev.dimension.flare.data.network.nodeinfo.PlatformDetector +import dev.dimension.flare.model.PlatformType + +internal data object TumblrPlatformDetector : PlatformDetector { + override val priority: Int = 70 + + override suspend fun detect(host: String): NodeData? { + val normalized = host.lowercase() + return if (normalized == "tumblr.com" || normalized == "www.tumblr.com" || normalized.endsWith(".tumblr.com")) { + NodeData( + host = host, + platformType = PlatformType.Tumblr, + software = PlatformType.Tumblr.name, + compatibleMode = false, + ) + } else { + null + } + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrService.kt new file mode 100644 index 000000000..11409e6bf --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/TumblrService.kt @@ -0,0 +1,196 @@ +package dev.dimension.flare.data.network.tumblr + +import dev.dimension.flare.data.network.ktorClient +import dev.dimension.flare.data.network.tumblr.model.TumblrBlogInfoResponse +import dev.dimension.flare.data.network.tumblr.model.TumblrPostsResponse +import dev.dimension.flare.data.network.tumblr.model.TumblrResponse +import dev.dimension.flare.data.network.tumblr.model.TumblrUserInfoResponse +import dev.dimension.flare.data.network.tumblr.model.TumblrWriteResponse +import io.ktor.client.call.body +import io.ktor.client.request.forms.submitForm +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.http.HttpHeaders +import io.ktor.http.Parameters +import io.ktor.http.URLBuilder +import io.ktor.http.appendPathSegments + +internal class TumblrService( + private val consumerKey: String, + private val accessToken: String? = null, +) { + private val client = ktorClient() + + suspend fun userInfo(): TumblrUserInfoResponse { + val url = "https://api.tumblr.com/v2/user/info" + return client + .get(url) { + accessToken?.let { + header(HttpHeaders.Authorization, "Bearer $it") + } + }.body>() + .response + } + + suspend fun dashboard( + offset: Int, + limit: Int, + ): TumblrPostsResponse { + val url = + URLBuilder("https://api.tumblr.com/") + .apply { + appendPathSegments("v2", "user", "dashboard") + parameters.append("offset", offset.toString()) + parameters.append("limit", limit.coerceIn(1, 20).toString()) + parameters.append("npf", "true") + parameters.append("reblog_info", "true") + parameters.append("notes_info", "true") + }.buildString() + return client + .get(url) { + accessToken?.let { + header(HttpHeaders.Authorization, "Bearer $it") + } + }.body>() + .response + } + + suspend fun blogInfo(blogIdentifier: String): TumblrBlogInfoResponse = + client + .get( + URLBuilder("https://api.tumblr.com/") + .apply { + appendPathSegments("v2", "blog", blogIdentifier, "info") + parameters.append("api_key", consumerKey) + }.buildString(), + ).body>() + .response + + suspend fun blogPosts( + blogIdentifier: String, + offset: Int, + limit: Int, + postId: String? = null, + ): TumblrPostsResponse = + client + .get( + URLBuilder("https://api.tumblr.com/") + .apply { + appendPathSegments("v2", "blog", blogIdentifier, "posts") + parameters.append("api_key", consumerKey) + postId?.let { + parameters.append("id", it) + } ?: run { + parameters.append("offset", offset.toString()) + parameters.append("limit", limit.coerceIn(1, 20).toString()) + } + parameters.append("npf", "true") + parameters.append("reblog_info", "true") + parameters.append("notes_info", "true") + }.buildString(), + ).body>() + .response + + suspend fun post( + blogIdentifier: String, + postId: String, + ) = blogPosts(blogIdentifier = blogIdentifier, offset = 0, limit = 1, postId = postId) + .posts + .firstOrNull { it.idString == postId || it.id.toString() == postId } + ?: error("Tumblr post not found: $blogIdentifier/$postId") + + suspend fun createTextPost( + blogIdentifier: String, + content: String, + ): TumblrWriteResponse = + authenticatedSubmitForm( + URLBuilder("https://api.tumblr.com/") + .apply { + appendPathSegments("v2", "blog", blogIdentifier, "post") + }.buildString(), + Parameters.build { + append("type", "text") + append("body", content) + }, + ) + + suspend fun reblogPost( + blogIdentifier: String, + postId: String, + reblogKey: String, + comment: String? = null, + ): TumblrWriteResponse = + authenticatedSubmitForm( + URLBuilder("https://api.tumblr.com/") + .apply { + appendPathSegments("v2", "blog", blogIdentifier, "post", "reblog") + }.buildString(), + Parameters.build { + append("id", postId) + append("reblog_key", reblogKey) + comment?.takeIf { it.isNotBlank() }?.let { + append("comment", it) + } + }, + ) + + suspend fun deletePost( + blogIdentifier: String, + postId: String, + ): TumblrWriteResponse = + authenticatedSubmitForm( + URLBuilder("https://api.tumblr.com/") + .apply { + appendPathSegments("v2", "blog", blogIdentifier, "post", "delete") + }.buildString(), + Parameters.build { + append("id", postId) + }, + ) + + suspend fun like( + postId: String, + reblogKey: String, + ): TumblrWriteResponse = + authenticatedSubmitForm( + URLBuilder("https://api.tumblr.com/") + .apply { + appendPathSegments("v2", "user", "like") + }.buildString(), + Parameters.build { + append("id", postId) + append("reblog_key", reblogKey) + }, + ) + + suspend fun unlike( + postId: String, + reblogKey: String, + ): TumblrWriteResponse = + authenticatedSubmitForm( + URLBuilder("https://api.tumblr.com/") + .apply { + appendPathSegments("v2", "user", "unlike") + }.buildString(), + Parameters.build { + append("id", postId) + append("reblog_key", reblogKey) + }, + ) + + private suspend fun authenticatedSubmitForm( + url: String, + parameters: Parameters, + ): TumblrWriteResponse = + client + .submitForm( + url = url, + formParameters = parameters, + ) { + require(!accessToken.isNullOrBlank()) { + "Tumblr authenticated endpoint requires an OAuth2 access token" + } + header(HttpHeaders.Authorization, "Bearer $accessToken") + }.body>() + .response +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/model/TumblrModels.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/model/TumblrModels.kt new file mode 100644 index 000000000..84164ec47 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/tumblr/model/TumblrModels.kt @@ -0,0 +1,119 @@ +package dev.dimension.flare.data.network.tumblr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement + +@Serializable +internal data class TumblrResponse( + val meta: TumblrMeta, + val response: T, +) + +@Serializable +internal data class TumblrMeta( + val status: Int, + val msg: String, +) + +@Serializable +internal data class TumblrConsumerCredential( + @SerialName("consumer_key") + val consumerKey: String, + @SerialName("consumer_secret") + val consumerSecret: String, +) + +@Serializable +internal data class TumblrApplicationCredential( + @SerialName("consumer_key") + val consumerKey: String, + @SerialName("consumer_secret") + val consumerSecret: String, + @SerialName("auth_state") + val authState: String? = null, +) + +@Serializable +internal data class TumblrOAuth2TokenResponse( + @SerialName("access_token") + val accessToken: String, + @SerialName("expires_in") + val expiresIn: Long? = null, + @SerialName("token_type") + val tokenType: String? = null, + val scope: String? = null, + @SerialName("refresh_token") + val refreshToken: String? = null, +) + +@Serializable +internal data class TumblrUserInfoResponse( + val user: TumblrUser, +) + +@Serializable +internal data class TumblrUser( + val name: String, + val blogs: List = emptyList(), +) + +@Serializable +internal data class TumblrBlogInfoResponse( + val blog: TumblrBlog, +) + +@Serializable +internal data class TumblrPostsResponse( + val posts: List = emptyList(), + val totalPosts: Int? = null, +) + +@Serializable +internal data class TumblrWriteResponse( + val id: Long? = null, + @SerialName("id_string") + val idString: String? = null, +) + +@Serializable +internal data class TumblrBlog( + val name: String, + val title: String? = null, + val url: String, + val description: String? = null, + val posts: Long? = null, + val followers: Long? = null, + val primary: Boolean = false, + @SerialName("can_post") + val canPost: Boolean = false, +) + +@Serializable +internal data class TumblrPost( + val id: Long, + @SerialName("id_string") + val idString: String? = null, + @SerialName("blog_name") + val blogName: String, + @SerialName("post_url") + val postUrl: String? = null, + val summary: String? = null, + val timestamp: Long? = null, + @SerialName("reblog_key") + val reblogKey: String? = null, + @SerialName("note_count") + val noteCount: Long? = null, + val liked: Boolean = false, + @SerialName("can_like") + val canLike: Boolean = false, + @SerialName("can_reblog") + val canReblog: Boolean = false, + val tags: List = emptyList(), + val type: String? = null, + val content: JsonArray? = null, + val trail: JsonArray? = null, + @SerialName("reblogged_root_id") + val rebloggedRootId: JsonElement? = null, +) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/TumblrPlatformSpec.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/TumblrPlatformSpec.kt new file mode 100644 index 000000000..23f8e184b --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/platform/TumblrPlatformSpec.kt @@ -0,0 +1,52 @@ +package dev.dimension.flare.data.platform + +import dev.dimension.flare.common.deeplink.DeepLinkMapping +import dev.dimension.flare.common.deeplink.DeepLinkPattern +import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource +import dev.dimension.flare.data.model.HomeTimelineTabItem +import dev.dimension.flare.data.model.IconType +import dev.dimension.flare.data.model.TabItem +import dev.dimension.flare.data.model.TimelineTabItem +import dev.dimension.flare.data.network.nodeinfo.PlatformDetector +import dev.dimension.flare.data.network.tumblr.TumblrPlatformDetector +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformSpec +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.model.PlatformTypeMetadata +import dev.dimension.flare.ui.model.UiIcon +import dev.dimension.flare.ui.model.UiInstanceMetadata +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal data object TumblrPlatformSpec : PlatformSpec { + override val type = PlatformType.Tumblr + override val metadata = + PlatformTypeMetadata( + displayName = "Tumblr", + icon = UiIcon.Tumblr, + ) + override val detector: PlatformDetector = TumblrPlatformDetector + + override fun agreementUrl(host: String): String? = "https://www.tumblr.com/policy/en/terms-of-service" + + override fun deepLinkPatterns(host: String): ImmutableList> = persistentListOf() + + override fun defaultTimelineTabs(accountKey: MicroBlogKey): ImmutableList = + persistentListOf( + HomeTimelineTabItem( + accountKey = accountKey, + title = "Tumblr", + icon = IconType.Material(UiIcon.Tumblr), + ), + ) + + override fun secondary(accountKey: MicroBlogKey): ImmutableList = persistentListOf() + + override suspend fun instanceMetadata(host: String): UiInstanceMetadata = + throw UnsupportedOperationException("${type.name} is not supported yet") + + override fun guestDataSource( + host: String, + locale: String, + ): MicroblogDataSource = throw UnsupportedOperationException("${type.name} guest data source is not supported yet") +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformSpec.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformSpec.kt index 00e6f2bea..6ce9d834d 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformSpec.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformSpec.kt @@ -11,6 +11,7 @@ import dev.dimension.flare.data.platform.BlueskyPlatformSpec import dev.dimension.flare.data.platform.MastodonPlatformSpec import dev.dimension.flare.data.platform.MisskeyPlatformSpec import dev.dimension.flare.data.platform.NostrPlatformSpec +import dev.dimension.flare.data.platform.TumblrPlatformSpec import dev.dimension.flare.data.platform.VvoPlatformSpec import dev.dimension.flare.data.platform.XqtPlatformSpec import dev.dimension.flare.ui.model.UiIcon @@ -54,6 +55,7 @@ internal val PlatformType.spec: PlatformSpec PlatformType.Mastodon -> MastodonPlatformSpec PlatformType.Misskey -> MisskeyPlatformSpec PlatformType.Bluesky -> BlueskyPlatformSpec + PlatformType.Tumblr -> TumblrPlatformSpec PlatformType.xQt -> XqtPlatformSpec PlatformType.VVo -> VvoPlatformSpec } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformType.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformType.kt index 78dfb06a1..a5d64dde5 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformType.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformType.kt @@ -11,6 +11,7 @@ public enum class PlatformType { Mastodon, Misskey, Bluesky, + Tumblr, @Suppress("EnumEntryName") // nothing wrong with this name :) xQt, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiAccount.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiAccount.kt index 3f42d4bfd..243335271 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiAccount.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiAccount.kt @@ -9,6 +9,7 @@ import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource import dev.dimension.flare.data.datasource.misskey.MisskeyDataSource import dev.dimension.flare.data.datasource.nostr.NostrDataSource import dev.dimension.flare.data.datasource.pleroma.PleromaDataSource +import dev.dimension.flare.data.datasource.tumblr.TumblrDataSource import dev.dimension.flare.data.datasource.vvo.VVODataSource import dev.dimension.flare.data.datasource.xqt.XQTDataSource import dev.dimension.flare.model.MicroBlogKey @@ -191,6 +192,33 @@ public sealed class UiAccount { } } + @Immutable + internal data class Tumblr( + override val accountKey: MicroBlogKey, + val blogIdentifier: String, + val blogName: String, + val blogUrl: String, + val userName: String? = null, + ) : UiAccount() { + override val platformType: PlatformType + get() = PlatformType.Tumblr + + @Immutable + @Serializable + @SerialName("TumblrCredential") + data class Credential( + val consumerKey: String, + val accessToken: String, + val refreshToken: String? = null, + val expiresIn: Long? = null, + val scope: String? = null, + val blogIdentifier: String, + val blogName: String, + val blogUrl: String, + val userName: String? = null, + ) : UiAccount.Credential + } + @Immutable internal data class XQT( override val accountKey: MicroBlogKey, @@ -255,6 +283,11 @@ public sealed class UiAccount { accountKey = accountKey, ) + is Tumblr -> + TumblrDataSource( + accountKey = accountKey, + ) + is XQT -> XQTDataSource( accountKey = accountKey, @@ -299,6 +332,17 @@ public sealed class UiAccount { ) } + PlatformType.Tumblr -> { + val credential = credential_json.decodeJson() + Tumblr( + accountKey = account_key, + blogIdentifier = credential.blogIdentifier, + blogName = credential.blogName, + blogUrl = credential.blogUrl, + userName = credential.userName, + ) + } + PlatformType.xQt -> { XQT( accountKey = account_key, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiApplication.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiApplication.kt index 7b3baaa37..ab210c83c 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiApplication.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiApplication.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import dev.dimension.flare.common.decodeJson import dev.dimension.flare.data.database.app.model.DbApplication import dev.dimension.flare.data.network.mastodon.api.model.CreateApplicationResponse +import dev.dimension.flare.data.network.tumblr.model.TumblrApplicationCredential import dev.dimension.flare.model.PlatformType import dev.dimension.flare.model.vvoHost import dev.dimension.flare.model.xqtHost @@ -34,6 +35,12 @@ public sealed interface UiApplication { override val host: String, ) : UiApplication + @Immutable + public data class Tumblr internal constructor( + override val host: String, + internal val credential: TumblrApplicationCredential, + ) : UiApplication + @Immutable public data object XQT : UiApplication { override val host: String = xqtHost @@ -70,6 +77,12 @@ public sealed interface UiApplication { host = host, ) + PlatformType.Tumblr -> + Tumblr( + host = host, + credential = credential_json.decodeJson(), + ) + PlatformType.xQt -> XQT PlatformType.VVo -> VVo 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 0adf849ff..c257efe1d 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 @@ -20,6 +20,7 @@ public enum class UiIcon { Mastodon, Misskey, Bluesky, + Tumblr, List, Feeds, Messages, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Tumblr.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Tumblr.kt new file mode 100644 index 000000000..67d0636c9 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Tumblr.kt @@ -0,0 +1,288 @@ +package dev.dimension.flare.ui.model.mapper + +import dev.dimension.flare.common.jsonObjectOrNull +import dev.dimension.flare.data.datasource.microblog.ActionMenu +import dev.dimension.flare.data.datasource.microblog.PostEvent +import dev.dimension.flare.data.datasource.microblog.userActionsMenu +import dev.dimension.flare.data.network.tumblr.model.TumblrBlog +import dev.dimension.flare.data.network.tumblr.model.TumblrPost +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.model.ClickEvent +import dev.dimension.flare.ui.model.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.UiProfile +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.render.RenderContent +import dev.dimension.flare.ui.render.RenderRun +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 kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import kotlin.time.Instant + +internal fun TumblrBlog.toUiProfile(): UiProfile { + val key = MicroBlogKey(name, TUMBLR_HOST) + return UiProfile( + key = key, + handle = UiHandle(name, TUMBLR_HOST), + avatar = tumblrAvatarUrl(name), + nameInternal = (title ?: name).toUiPlainText(), + platformType = PlatformType.Tumblr, + clickEvent = ClickEvent.Deeplink(DeeplinkRoute.Profile.User(AccountType.Specific(key), key)), + banner = null, + description = description?.takeIf { it.isNotBlank() }?.toUiPlainText(), + matrices = + UiProfile.Matrices( + fansCount = followers ?: 0L, + followsCount = 0L, + statusesCount = posts ?: 0L, + ), + mark = persistentListOf(), + bottomContent = null, + ) +} + +internal fun TumblrPost.toUi(accountType: AccountType): UiTimelineV2.Post { + val blogKey = MicroBlogKey(blogName, TUMBLR_HOST) + val statusKey = MicroBlogKey(idString ?: id.toString(), blogName) + val accountKey = (accountType as? AccountType.Specific)?.accountKey + val isFromMe = accountKey?.id == blogName + val shareUrl = postUrl ?: "https://$blogName.tumblr.com/post/${idString ?: id}" + val images = extractImages(content) + val richText = content.toTumblrRichText(summary) + return UiTimelineV2.Post( + platformType = PlatformType.Tumblr, + images = images.toPersistentList(), + sensitive = false, + contentWarning = null, + user = + UiProfile( + key = blogKey, + handle = UiHandle(blogName, TUMBLR_HOST), + avatar = tumblrAvatarUrl(blogName), + nameInternal = blogName.toUiPlainText(), + platformType = PlatformType.Tumblr, + clickEvent = ClickEvent.Deeplink(DeeplinkRoute.Profile.User(accountType, blogKey)), + banner = null, + description = null, + matrices = UiProfile.Matrices(0, 0, 0), + mark = persistentListOf(), + bottomContent = null, + ), + content = richText, + actions = + buildList { + if (canReblog && accountKey != null) { + add( + ActionMenu.tumblrReblog( + statusKey = statusKey, + canReblog = canReblog, + accountKey = accountKey, + ), + ) + } + if (canLike && accountKey != null) { + add( + ActionMenu.tumblrLike( + statusKey = statusKey, + liked = liked, + accountKey = accountKey, + ), + ) + } + add( + ActionMenu.Group( + displayItem = + ActionMenu.Item( + icon = UiIcon.More, + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.More), + ), + actions = + buildList { + 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, + shareUrl = shareUrl, + ), + ), + ), + ) + if (isFromMe) { + 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( + accountType = accountType, + statusKey = statusKey, + ), + ), + ), + ) + } else if (accountKey != null) { + add(ActionMenu.Divider) + addAll( + userActionsMenu( + accountKey = accountKey, + userKey = blogKey, + handle = blogKey.id, + ), + ) + } + }.toPersistentList(), + ), + ) + }.toPersistentList(), + poll = null, + statusKey = statusKey, + card = postUrl?.let { UiCard(title = summary ?: blogName, url = it, media = null, description = null) }, + createdAt = Instant.fromEpochSeconds(timestamp ?: 0L).toUi(), + emojiReactions = persistentListOf(), + sourceChannel = null, + visibility = UiTimelineV2.Post.Visibility.Public, + replyToHandle = null, + references = persistentListOf(), + parents = persistentListOf(), + quote = persistentListOf(), + message = null, + clickEvent = ClickEvent.Deeplink(DeeplinkRoute.Status.Detail(statusKey, accountType)), + accountType = accountType, + ) +} + +private fun JsonArray?.toTumblrRichText(fallback: String?): dev.dimension.flare.ui.render.UiRichText { + val renderBlocks: List = + this + ?.mapNotNull { block -> + val obj = block.jsonObjectOrNull ?: return@mapNotNull null + when (obj["type"]?.jsonPrimitive?.contentOrNull) { + "text" -> + RenderContent.Text( + runs = + persistentListOf( + RenderRun.Text( + text = obj["text"]?.jsonPrimitive?.contentOrNull.orEmpty(), + ), + ), + ) + + "image" -> { + val mediaUrl = + obj["media"] + ?.jsonArray + ?.firstOrNull() + ?.jsonObjectOrNull + ?.get("url") + ?.jsonPrimitive + ?.contentOrNull + mediaUrl?.let { + RenderContent.BlockImage(url = it, href = null) + } + } + + else -> null + } + }.orEmpty() + return if (renderBlocks.isNotEmpty()) { + uiRichTextOf(renderBlocks) + } else { + (fallback ?: "").toUiPlainText() + } +} + +private fun extractImages(content: JsonArray?): List = + content + ?.mapNotNull { block -> + val obj = block.jsonObjectOrNull ?: return@mapNotNull null + if (obj["type"]?.jsonPrimitive?.contentOrNull != "image") { + return@mapNotNull null + } + val media = + obj["media"] + ?.jsonArray + ?.firstOrNull() + ?.jsonObjectOrNull + val url = media?.get("url")?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null + UiMedia.Image(url) + }.orEmpty() + +internal const val TUMBLR_HOST: String = "tumblr.com" + +internal fun tumblrAvatarUrl(blogName: String): String = "https://api.tumblr.com/v2/blog/$blogName.tumblr.com/avatar/128" + +internal fun ActionMenu.Companion.tumblrLike( + statusKey: MicroBlogKey, + liked: Boolean, + 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 + }, + ), + color = if (liked) ActionMenu.Item.Color.Red else null, + clickEvent = + ClickEvent.event(accountKey) { + PostEvent.Tumblr.Like( + postKey = statusKey, + liked = liked, + accountKey = it, + ) + }, + ) + +internal fun ActionMenu.Companion.tumblrReblog( + statusKey: MicroBlogKey, + canReblog: Boolean, + accountKey: MicroBlogKey, +): ActionMenu.Item = + ActionMenu.Item( + updateKey = "tumblr_reblog_$statusKey", + icon = if (canReblog) UiIcon.Retweet else UiIcon.Unretweet, + text = + ActionMenu.Item.Text.Localized( + if (canReblog) { + ActionMenu.Item.Text.Localized.Type.Retweet + } else { + ActionMenu.Item.Text.Localized.Type.Unretweet + }, + ), + color = if (canReblog) null else ActionMenu.Item.Color.PrimaryColor, + clickEvent = + if (canReblog) { + ClickEvent.event(accountKey) { + PostEvent.Tumblr.Reblog( + postKey = statusKey, + canReblog = true, + accountKey = it, + ) + } + } else { + ClickEvent.Noop + }, + ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/InitialTextResolver.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/InitialTextResolver.kt index bcd6fbdbd..b9499d0fd 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/InitialTextResolver.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/InitialTextResolver.kt @@ -18,7 +18,11 @@ internal object InitialTextResolver { PlatformType.VVo -> resolveVVo(post, composeStatus) PlatformType.Mastodon, PlatformType.Misskey -> resolveMastodonMisskey(post, composeStatus, currentUserHandle, selectedAccountKey) - else -> null + PlatformType.Tumblr, + PlatformType.Bluesky, + PlatformType.Nostr, + PlatformType.xQt, + -> null } private fun resolveVVo( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/ServiceSelectPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/ServiceSelectPresenter.kt index 204b7610a..253020704 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/ServiceSelectPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/ServiceSelectPresenter.kt @@ -28,13 +28,16 @@ public class ServiceSelectPresenter( val blueskyOauthLoginState = remember { BlueskyOAuthLoginPresenter(toHome) }.body() val mastodonLoginState = mastodonLoginPresenter(toHome) val misskeyLoginState = misskeyLoginPresenter(toHome) + val tumblrLoginState = tumblrLoginPresenter(toHome) val loading = nostrLoginState.loading || blueskyLoginState.loading || mastodonLoginState.loading || mastodonLoginState.resumedState is UiState.Loading || misskeyLoginState.loading || - misskeyLoginState.resumedState is UiState.Loading + misskeyLoginState.resumedState is UiState.Loading || + tumblrLoginState.loading || + tumblrLoginState.resumedState is UiState.Loading return object : ServiceSelectState, NodeInfoState by nodeInfoState { override val nostrLoginState = nostrLoginState @@ -42,10 +45,56 @@ public class ServiceSelectPresenter( override val blueskyOauthLoginState = blueskyOauthLoginState override val mastodonLoginState = mastodonLoginState override val misskeyLoginState = misskeyLoginState + override val tumblrLoginState = tumblrLoginState override val loading = loading } } + @Composable + private fun tumblrLoginPresenter(onBack: (() -> Unit)?): TumblrLoginState { + var loading by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + var callbackUrl by remember { mutableStateOf(null) } + val resumedState = + callbackUrl?.let { + remember { + TumblrCallbackPresenter( + callbackUrl = callbackUrl, + toHome = { + callbackUrl = null + loading = false + error = null + onBack?.invoke() + }, + ) + }.body() + } + return object : TumblrLoginState { + override val loading = loading + override val error = error + override val resumedState = resumedState + + override fun login(launchUrl: (String) -> Unit) { + scope.launch { + loading = true + error = null + tumblrLoginUseCase( + applicationRepository = applicationRepository, + launchOAuth = launchUrl, + ).onFailure { + error = it.message + loading = false + } + } + } + + override fun resume(url: String) { + callbackUrl = url + } + } + } + @Composable private fun misskeyLoginPresenter(onBack: (() -> Unit)?): MisskeyLoginState { var loading by remember { mutableStateOf(false) } @@ -152,6 +201,7 @@ public interface ServiceSelectState : NodeInfoState { public val blueskyOauthLoginState: BlueskyOAuthLoginPresenter.State public val mastodonLoginState: MastodonLoginState public val misskeyLoginState: MisskeyLoginState + public val tumblrLoginState: TumblrLoginState public val loading: Boolean } @@ -182,3 +232,14 @@ public interface MisskeyLoginState { public fun resume(url: String) } + +@Immutable +public interface TumblrLoginState { + public val loading: Boolean + public val error: String? + public val resumedState: UiState? + + public fun login(launchUrl: (String) -> Unit) + + public fun resume(url: String) +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/TumblrCallbackPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/TumblrCallbackPresenter.kt new file mode 100644 index 000000000..0a7ae2fe3 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/TumblrCallbackPresenter.kt @@ -0,0 +1,114 @@ +package dev.dimension.flare.ui.presenter.login + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import dev.dimension.flare.common.encodeJson +import dev.dimension.flare.data.network.tumblr.TumblrOAuth2Config +import dev.dimension.flare.data.network.tumblr.TumblrOAuth2Service +import dev.dimension.flare.data.network.tumblr.TumblrService +import dev.dimension.flare.data.repository.AccountRepository +import dev.dimension.flare.data.repository.ApplicationRepository +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.model.UiAccount +import dev.dimension.flare.ui.model.UiApplication +import dev.dimension.flare.ui.model.UiState +import dev.dimension.flare.ui.presenter.PresenterBase +import io.ktor.http.Url +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import kotlin.uuid.Uuid + +internal class TumblrCallbackPresenter( + private val callbackUrl: String?, + private val toHome: () -> Unit, +) : PresenterBase>(), + KoinComponent { + private val applicationRepository: ApplicationRepository by inject() + private val accountRepository: AccountRepository by inject() + + @Composable + override fun body(): UiState { + if (callbackUrl == null) { + return UiState.Error(Exception("No callback URL")) + } + var error by remember { mutableStateOf(null) } + LaunchedEffect(callbackUrl) { + val pendingOAuth = applicationRepository.getPendingOAuth() + if (pendingOAuth !is UiApplication.Tumblr) { + error = Exception("Invalid pending OAuth: $pendingOAuth") + return@LaunchedEffect + } + runCatching { + val parsed = Url(callbackUrl) + val code = parsed.parameters["code"] ?: error("No code") + val state = parsed.parameters["state"] ?: error("No state") + val expectedState = pendingOAuth.credential.authState ?: error("No pending OAuth state") + require(state == expectedState) { "Tumblr OAuth state mismatch" } + val token = + TumblrOAuth2Service().requestToken( + code = code, + clientId = pendingOAuth.credential.consumerKey, + clientSecret = pendingOAuth.credential.consumerSecret, + redirectUri = TumblrOAuth2Config.REDIRECT_URI, + ) + val userInfo = + TumblrService( + consumerKey = pendingOAuth.credential.consumerKey, + accessToken = token.accessToken, + ).userInfo() + val primaryBlog = + userInfo.user.blogs.firstOrNull { it.primary } + ?: userInfo.user.blogs.firstOrNull() + ?: error("Tumblr user has no blogs") + accountRepository.addAccount( + UiAccount.Tumblr( + accountKey = MicroBlogKey(primaryBlog.name, "tumblr.com"), + blogIdentifier = "${primaryBlog.name}.tumblr.com", + blogName = primaryBlog.name, + blogUrl = primaryBlog.url, + userName = userInfo.user.name, + ), + credential = + UiAccount.Tumblr.Credential( + consumerKey = pendingOAuth.credential.consumerKey, + accessToken = token.accessToken, + refreshToken = token.refreshToken, + expiresIn = token.expiresIn, + scope = token.scope, + blogIdentifier = "${primaryBlog.name}.tumblr.com", + blogName = primaryBlog.name, + blogUrl = primaryBlog.url, + userName = userInfo.user.name, + ), + ) + applicationRepository.setPendingOAuth(pendingOAuth.host, false) + toHome() + }.onFailure { + error = it + } + } + return error?.let { UiState.Error(it) } ?: UiState.Loading() + } +} + +internal suspend fun tumblrLoginUseCase( + applicationRepository: ApplicationRepository, + launchOAuth: (String) -> Unit, +): Result = + runCatching { + val state = Uuid.random().toString() + val credential = TumblrOAuth2Config.applicationCredential(state = state) + applicationRepository.addApplication( + host = TumblrOAuth2Config.HOST, + credentialJson = credential.encodeJson(), + platformType = PlatformType.Tumblr, + ) + applicationRepository.clearPendingOAuth() + applicationRepository.setPendingOAuth(TumblrOAuth2Config.HOST, true) + launchOAuth(TumblrOAuth2Config.buildAuthorizeUrl(state = state)) + } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/route/DeeplinkRoute.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/route/DeeplinkRoute.kt index bf7afaebe..468a93dc9 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/route/DeeplinkRoute.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/route/DeeplinkRoute.kt @@ -233,6 +233,7 @@ public sealed class DeeplinkRoute { public const val MASTODON: String = "$APPSCHEMA://Callback/SignIn/Mastodon" public const val MISSKEY: String = "$APPSCHEMA://Callback/SignIn/Misskey" public const val BLUESKY: String = "$APPSCHEMA://Callback/SignIn/Bluesky" + public const val TUMBLR: String = "$APPSCHEMA://Callback/SignIn/Tumblr" } @OptIn(ExperimentalSerializationApi::class)