From c645889461b4088ad932f2434ddc6f48f027ec16 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sat, 20 Jun 2026 19:53:55 +0900 Subject: [PATCH 1/9] initial fanbox support --- app/build.gradle.kts | 1 + .../flare/di/AndroidKoinApplication.kt | 2 + .../dev/dimension/flare/ui/route/Route.kt | 13 + .../dev/dimension/flare/ui/route/Router.kt | 2 + .../ui/screen/article/ArticleEntryBuilder.kt | 17 + .../flare/ui/screen/article/ArticleScreen.kt | 921 ++++++++++++++++++ .../flare/ui/screen/settings/EditTabDialog.kt | 2 + app/src/main/res/values-en-rUS/strings.xml | 3 + app/src/main/res/values-zh-rCN/strings.xml | 3 + app/src/main/res/values-zh-rTW/strings.xml | 3 + app/src/main/res/values/strings.xml | 5 + .../composeResources/values/strings.xml | 2 + .../dimension/flare/ui/component/TabIcon.kt | 4 + .../dimension/flare/ui/component/UiIconExt.kt | 1 + .../flare/ui/model/PlatformTypeIcon.kt | 1 + desktopApp/build.gradle.kts | 1 + .../main/composeResources/values/strings.xml | 2 + .../flare/di/DesktopKoinApplication.kt | 2 + .../flare/ui/screen/home/EditTabDialog.kt | 4 + .../agent/common/AgentSubscriptionTools.kt | 33 +- .../feature/agent/common/AgentToolsTest.kt | 13 +- .../dimension/flare/ui/model/mapper/Rss.kt | 50 +- ios-shared/build.gradle.kts | 2 + .../flare/ios/shared/IosSharedHelper.kt | 2 + .../Component/Status/StatusActionView.swift | 1 + .../UI/Component/Status/StatusView.swift | 4 + iosApp/flare/UI/Component/TabIcon.swift | 2 + iosApp/flare/UI/Screen/ComposeScreen.swift | 2 + .../UI/Screen/ServiceSelectionScreen.swift | 2 + settings.gradle.kts | 1 + .../data/datasource/microblog/PostEvent.kt | 21 + .../microblog/datasource/ArticleDataSource.kt | 10 + .../flare/data/model/tab/TimelineSpecIds.kt | 3 + .../dev/dimension/flare/model/PlatformType.kt | 1 + .../dev/dimension/flare/ui/model/UiArticle.kt | 177 ++++ .../dev/dimension/flare/ui/model/UiIcon.kt | 1 + .../dev/dimension/flare/ui/model/UiStrings.kt | 2 + .../dimension/flare/ui/model/UiTimelineV2.kt | 18 +- .../flare/ui/model/mapper/FanboxActionMenu.kt | 45 + .../ui/presenter/article/ArticlePresenter.kt | 132 +++ .../dimension/flare/ui/route/DeeplinkRoute.kt | 6 + .../microblog/MixedRemoteMediatorTest.kt | 1 - .../dimension/flare/ui/model/UiArticleTest.kt | 132 +++ social/fanbox/build.gradle.kts | 76 ++ .../datasource/fanbox/FanboxDataSource.kt | 268 +++++ .../data/datasource/fanbox/FanboxMapper.kt | 560 +++++++++++ .../datasource/fanbox/FanboxProfileLoaders.kt | 238 +++++ .../fanbox/FanboxTimelineLoaders.kt | 302 ++++++ .../flare/data/network/fanbox/FanboxModels.kt | 400 ++++++++ .../network/fanbox/FanboxPlatformDetector.kt | 23 + .../data/network/fanbox/FanboxService.kt | 135 +++ .../network/fanbox/api/FanboxResources.kt | 113 +++ .../flare/data/platform/FanboxCredential.kt | 19 + .../flare/data/platform/FanboxPlatformSpec.kt | 110 +++ .../ui/presenter/login/FanboxLoginProvider.kt | 195 ++++ web/src/lib/i18n/uiStrings.ts | 4 + 56 files changed, 4064 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/dev/dimension/flare/ui/screen/article/ArticleEntryBuilder.kt create mode 100644 app/src/main/java/dev/dimension/flare/ui/screen/article/ArticleScreen.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/datasource/ArticleDataSource.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiArticle.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/FanboxActionMenu.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/article/ArticlePresenter.kt create mode 100644 shared/src/commonTest/kotlin/dev/dimension/flare/ui/model/UiArticleTest.kt create mode 100644 social/fanbox/build.gradle.kts create mode 100644 social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/datasource/fanbox/FanboxDataSource.kt create mode 100644 social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/datasource/fanbox/FanboxMapper.kt create mode 100644 social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/datasource/fanbox/FanboxProfileLoaders.kt create mode 100644 social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/datasource/fanbox/FanboxTimelineLoaders.kt create mode 100644 social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/FanboxModels.kt create mode 100644 social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/FanboxPlatformDetector.kt create mode 100644 social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/FanboxService.kt create mode 100644 social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/api/FanboxResources.kt create mode 100644 social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/platform/FanboxCredential.kt create mode 100644 social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/platform/FanboxPlatformSpec.kt create mode 100644 social/fanbox/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/FanboxLoginProvider.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 250780fc1..23d2ac7e5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -127,6 +127,7 @@ dependencies { implementation(libs.compose.webview) implementation(projects.shared) implementation(projects.social.bluesky) + implementation(projects.social.fanbox) implementation(projects.social.mastodon) implementation(projects.social.misskey) implementation(projects.social.nostr) 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 f662fbf9b..a3c95d9e2 100644 --- a/app/src/main/java/dev/dimension/flare/di/AndroidKoinApplication.kt +++ b/app/src/main/java/dev/dimension/flare/di/AndroidKoinApplication.kt @@ -2,6 +2,7 @@ package dev.dimension.flare.di import dev.dimension.flare.data.platform.AllRssTimelineLoaderFactory import dev.dimension.flare.data.platform.BlueskyPlatformSpec +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.NostrPlatformSpec @@ -33,6 +34,7 @@ internal fun runtimeData(allRssTimelineLoaderFactory: AllRssTimelineLoaderFactor MastodonPlatformSpec, MisskeyPlatformSpec, BlueskyPlatformSpec, + FanboxPlatformSpec, PixivPlatformSpec, XqtPlatformSpec, VvoPlatformSpec, diff --git a/app/src/main/java/dev/dimension/flare/ui/route/Route.kt b/app/src/main/java/dev/dimension/flare/ui/route/Route.kt index 1c1fbe968..9e21eb0e3 100644 --- a/app/src/main/java/dev/dimension/flare/ui/route/Route.kt +++ b/app/src/main/java/dev/dimension/flare/ui/route/Route.kt @@ -472,6 +472,12 @@ internal sealed interface Route : NavKey { val articleId: String? = null, ) : Route + @Serializable + data class Article( + val accountType: AccountType, + val articleKey: MicroBlogKey, + ) : Route + @Serializable data class BlockUser( val accountType: AccountType?, @@ -621,6 +627,13 @@ internal sealed interface Route : NavKey { ) } + is DeeplinkRoute.Article -> { + Article( + accountType = deeplinkRoute.accountType, + articleKey = deeplinkRoute.articleKey, + ) + } + is DeeplinkRoute.Search -> { Search( accountType = deeplinkRoute.accountType, diff --git a/app/src/main/java/dev/dimension/flare/ui/route/Router.kt b/app/src/main/java/dev/dimension/flare/ui/route/Router.kt index 184798c24..76c74de4f 100644 --- a/app/src/main/java/dev/dimension/flare/ui/route/Router.kt +++ b/app/src/main/java/dev/dimension/flare/ui/route/Router.kt @@ -18,6 +18,7 @@ import androidx.navigation3.scene.DialogSceneStrategy import androidx.navigation3.ui.NavDisplay import dev.dimension.flare.ui.component.BottomSheetSceneStrategy import dev.dimension.flare.ui.component.platform.isBigScreen +import dev.dimension.flare.ui.screen.article.articleEntryBuilder import dev.dimension.flare.ui.screen.bluesky.blueskyEntryBuilder import dev.dimension.flare.ui.screen.compose.composeEntryBuilder import dev.dimension.flare.ui.screen.dm.dmEntryBuilder @@ -96,6 +97,7 @@ internal fun Router( entryProvider = entryProvider { homeEntryBuilder(navigate, onBack, openDrawer, uriHandler = uriHandler) + articleEntryBuilder(onBack) blueskyEntryBuilder(navigate, onBack) composeEntryBuilder(navigate, onBack) dmEntryBuilder(navigate, onBack) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/article/ArticleEntryBuilder.kt b/app/src/main/java/dev/dimension/flare/ui/screen/article/ArticleEntryBuilder.kt new file mode 100644 index 000000000..db0963da5 --- /dev/null +++ b/app/src/main/java/dev/dimension/flare/ui/screen/article/ArticleEntryBuilder.kt @@ -0,0 +1,17 @@ +package dev.dimension.flare.ui.screen.article + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import dev.dimension.flare.ui.route.Route + +internal fun EntryProviderScope.articleEntryBuilder( + onBack: () -> Unit, +) { + entry { args -> + ArticleScreen( + accountType = args.accountType, + articleKey = args.articleKey, + onBack = onBack, + ) + } +} diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/article/ArticleScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/article/ArticleScreen.kt new file mode 100644 index 000000000..71643a5ff --- /dev/null +++ b/app/src/main/java/dev/dimension/flare/ui/screen/article/ArticleScreen.kt @@ -0,0 +1,921 @@ +package dev.dimension.flare.ui.screen.article + +import android.content.Context +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Brands +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.brands.Chrome +import compose.icons.fontawesomeicons.solid.File +import compose.icons.fontawesomeicons.solid.Globe +import compose.icons.fontawesomeicons.solid.Lock +import compose.icons.fontawesomeicons.solid.ShareNodes +import dev.dimension.flare.R +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.component.AvatarComponentDefaults +import dev.dimension.flare.ui.component.BackButton +import dev.dimension.flare.ui.component.DateTimeText +import dev.dimension.flare.ui.component.ErrorContent +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.FavIcon +import dev.dimension.flare.ui.component.FlareScaffold +import dev.dimension.flare.ui.component.FlareTopAppBar +import dev.dimension.flare.ui.component.NetworkImage +import dev.dimension.flare.ui.component.RichText +import dev.dimension.flare.ui.component.VideoPlayer +import dev.dimension.flare.ui.component.placeholder +import dev.dimension.flare.ui.component.status.CommonStatusHeaderComponent +import dev.dimension.flare.ui.model.ClickContext +import dev.dimension.flare.ui.model.UiArticle +import dev.dimension.flare.ui.model.UiArticleAuthor +import dev.dimension.flare.ui.model.UiArticleBlock +import dev.dimension.flare.ui.model.UiArticleContentGateReason +import dev.dimension.flare.ui.model.UiMedia +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.UiState +import dev.dimension.flare.ui.model.takeSuccess +import dev.dimension.flare.ui.presenter.article.ArticlePresenter +import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.render.UiDateTime +import dev.dimension.flare.ui.route.DeeplinkRoute +import dev.dimension.flare.ui.route.toUri +import dev.dimension.flare.ui.theme.isLightTheme +import dev.dimension.flare.ui.theme.screenHorizontalPadding +import io.ktor.http.Url +import moe.tlaster.precompose.molecule.producePresenter + +private val ArticleCoverHeight = 260.dp +private const val ARTICLE_COVER_KEY = "cover" +private const val ARTICLE_HEADER_KEY = "header" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ArticleScreen( + accountType: AccountType, + articleKey: MicroBlogKey, + onBack: () -> Unit, +) { + var refreshKey by remember(accountType, articleKey) { mutableIntStateOf(0) } + val state by producePresenter("article_$accountType-$articleKey-$refreshKey") { + remember { + ArticlePresenter( + accountType = accountType, + articleKey = articleKey, + ) + }.invoke() + } + val listState = rememberLazyListState() + val uriHandler = LocalUriHandler.current + val context = LocalContext.current + val article = state.article.takeSuccess() + val articleTitle = article?.title + var titleHeightPx by remember(article?.key) { mutableIntStateOf(0) } + val sourceUrl = article?.sourceUrl?.takeIf { it.isNotBlank() } + val hasCover = article?.cover != null + val appBarBottomPx = + with(LocalDensity.current) { + TopAppBarDefaults.TopAppBarExpandedHeight.toPx() + + WindowInsets.statusBars.getTop(this) + } + val titleAppBarAlpha by remember( + article?.key, + articleTitle, + hasCover, + listState, + titleHeightPx, + appBarBottomPx, + ) { + derivedStateOf { + if (articleTitle == null) { + 0f + } else { + val headerIndex = if (hasCover) 1 else 0 + val headerItem = + listState.layoutInfo.visibleItemsInfo.firstOrNull { + it.key == ARTICLE_HEADER_KEY + } + headerItem?.offset?.let { offset -> + if (titleHeightPx > 0) { + ((appBarBottomPx - offset) / titleHeightPx).coerceIn(0f, 1f) + } else { + if (offset < appBarBottomPx) { + 1f + } else { + 0f + } + } + } ?: if (listState.firstVisibleItemIndex > headerIndex) { + 1f + } else { + 0f + } + } + } + } + val coverScrollRangePx = + with(LocalDensity.current) { + ( + ArticleCoverHeight.toPx() - + TopAppBarDefaults.TopAppBarExpandedHeight.toPx() - + WindowInsets.statusBars.getTop(this) + ).coerceAtLeast(1f) + } + val appBarAlpha by remember(hasCover, listState, coverScrollRangePx) { + derivedStateOf { + if (!hasCover) { + 1f + } else if (listState.firstVisibleItemIndex > 0) { + 1f + } else { + (listState.firstVisibleItemScrollOffset / coverScrollRangePx).coerceIn(0f, 1f) + } + } + } + val color = + if (isLightTheme()) { + MaterialTheme.colorScheme.surface + } else { + MaterialTheme.colorScheme.background + } + val appBarColor = + if (hasCover) { + color.copy(alpha = appBarAlpha) + } else { + color + } + FlareScaffold( + containerColor = color, + topBar = { + FlareTopAppBar( + title = { + if (titleAppBarAlpha > 0f && articleTitle != null) { + Text( + text = articleTitle, + modifier = + Modifier.graphicsLayer { + alpha = titleAppBarAlpha + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + navigationIcon = { + BackButton(onBack = onBack) + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = appBarColor, + scrolledContainerColor = appBarColor, + navigationIconContentColor = MaterialTheme.colorScheme.primary, + actionIconContentColor = MaterialTheme.colorScheme.primary, + ), + actions = { + Row( + modifier = + Modifier.background( + color, + MaterialTheme.shapes.medium, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + enabled = sourceUrl != null, + onClick = { + sourceUrl?.let(uriHandler::openUri) + }, + ) { + FAIcon( + FontAwesomeIcons.Brands.Chrome, + contentDescription = stringResource(R.string.rss_detail_open_in_browser), + ) + } + IconButton( + enabled = sourceUrl != null, + onClick = { + article?.let { + shareArticle(context, it) + } + }, + ) { + FAIcon( + FontAwesomeIcons.Solid.ShareNodes, + contentDescription = stringResource(R.string.rss_detail_share), + ) + } + } + }, + ) + }, + ) { contentPadding -> + when (val articleState = state.article) { + is UiState.Success -> { + ArticleSuccessContent( + article = articleState.data, + contentPadding = contentPadding, + listState = listState, + onProfileClick = { profile -> + profile.onClicked( + ClickContext( + launcher = uriHandler::openUri, + ), + ) + }, + onTitleMeasured = { + titleHeightPx = it + }, + onOpenUrl = uriHandler::openUri, + ) + } + + is UiState.Loading -> { + ArticleLoadingContent( + contentPadding = contentPadding, + listState = listState, + ) + } + + is UiState.Error -> { + Box( + modifier = + Modifier + .fillMaxSize() + .padding(contentPadding), + contentAlignment = Alignment.Center, + ) { + ErrorContent( + error = articleState.throwable, + onRetry = { + refreshKey++ + }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } +} + +@Composable +private fun ArticleSuccessContent( + article: UiArticle, + contentPadding: PaddingValues, + listState: LazyListState, + onProfileClick: (UiProfile) -> Unit, + onTitleMeasured: (Int) -> Unit, + onOpenUrl: (String) -> Unit, +) { + val layoutDirection = LocalLayoutDirection.current + val listContentPadding = + PaddingValues( + start = contentPadding.calculateStartPadding(layoutDirection), + top = + if (article.cover == null) { + contentPadding.calculateTopPadding() + } else { + 0.dp + }, + end = contentPadding.calculateEndPadding(layoutDirection), + bottom = contentPadding.calculateBottomPadding(), + ) + SelectionContainer { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = listContentPadding, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + article.cover?.let { cover -> + item(key = ARTICLE_COVER_KEY) { + ArticleCover( + cover = cover, + title = article.title, + ) + } + } + item(key = ARTICLE_HEADER_KEY) { + ArticleBodyContainer { + ArticleHeader( + article = article, + onProfileClick = onProfileClick, + onTitleMeasured = onTitleMeasured, + ) + } + } + item(key = "divider") { + ArticleBodyContainer { + HorizontalDivider() + } + } + items( + items = article.content.blocks, + key = UiArticleBlock::key, + ) { block -> + ArticleBodyContainer { + ArticleBlock( + block = block, + onOpenUrl = onOpenUrl, + ) + } + } + } + } +} + +@Composable +private fun ArticleCover( + cover: UiMedia.Image, + title: String, +) { + NetworkImage( + model = cover.url, + contentDescription = title, + customHeaders = cover.customHeaders, + contentScale = ContentScale.Crop, + modifier = + Modifier + .fillMaxWidth() + .height(ArticleCoverHeight), + ) +} + +@Composable +private fun ArticleHeader( + article: UiArticle, + onProfileClick: (UiProfile) -> Unit, + onTitleMeasured: (Int) -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = article.title, + style = MaterialTheme.typography.headlineMedium, + modifier = + Modifier.onSizeChanged { + onTitleMeasured(it.height) + }, + ) + when (val author = article.author) { + is UiArticleAuthor.Profile -> { + CommonStatusHeaderComponent( + data = author.profile, + onUserClick = { + onProfileClick(author.profile) + }, + trailing = { + article.publishDate?.let { + DateTimeText( + data = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fullTime = true, + ) + } + }, + ) + } + + is UiArticleAuthor.Rss -> { + ArticleRssAuthor( + author = author, + sourceUrl = article.sourceUrl, + publishDate = article.publishDate, + ) + } + + null -> { + article.publishDate?.let { + DateTimeText( + data = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fullTime = true, + ) + } + } + } + } +} + +@Composable +private fun ArticleRssAuthor( + author: UiArticleAuthor.Rss, + sourceUrl: String?, + publishDate: UiDateTime?, +) { + if (author.siteName == null && author.byline == null && publishDate == null) { + return + } + val host = + remember(sourceUrl) { + sourceUrl?.let { + runCatching { Url(it).host }.getOrNull() + } + } + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + author.siteName?.let { siteName -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (host != null) { + FavIcon( + host = host, + size = 16.dp, + ) + } else { + author.iconUrl?.let { + NetworkImage( + model = it, + contentDescription = siteName, + modifier = Modifier.size(16.dp), + ) + } + } + Text( + text = siteName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + author.byline?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f, fill = false), + ) + } + Spacer(modifier = Modifier.weight(1f)) + publishDate?.let { + DateTimeText( + data = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fullTime = true, + ) + } + } + } +} + +@Composable +private fun ArticleBlock( + block: UiArticleBlock, + onOpenUrl: (String) -> Unit, +) { + when (block) { + is UiArticleBlock.Text -> { + RichText( + text = block.richText, + overflow = TextOverflow.Clip, + ) + } + + is UiArticleBlock.Image -> { + ArticleImageBlock( + media = block.media, + onOpenUrl = onOpenUrl, + ) + } + + is UiArticleBlock.Video -> { + ArticleVideoBlock(media = block.media) + } + + is UiArticleBlock.File -> { + ArticleFileBlock( + block = block, + onOpenUrl = onOpenUrl, + ) + } + + is UiArticleBlock.Embed -> { + ArticleEmbedBlock( + block = block, + onOpenUrl = onOpenUrl, + ) + } + + is UiArticleBlock.ContentGate -> { + ArticleContentGateBlock( + block = block, + onOpenUrl = onOpenUrl, + ) + } + } +} + +@Composable +private fun ArticleImageBlock( + media: UiMedia.Image, + onOpenUrl: (String) -> Unit, +) { + NetworkImage( + model = media.url, + contentDescription = media.description, + customHeaders = media.customHeaders, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(media.aspectRatio.coerceIn(0.2f, 4f)) + .clip(MaterialTheme.shapes.medium) + .clickable { + onOpenUrl( + DeeplinkRoute + .Media + .Image( + uri = media.url, + previewUrl = media.previewUrl, + customHeaders = media.customHeaders, + ).toUri(), + ) + }, + ) +} + +@Composable +private fun ArticleVideoBlock(media: UiMedia.Video) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(media.aspectRatio.coerceIn(0.2f, 4f)), + contentAlignment = Alignment.Center, + ) { + VideoPlayer( + uri = media.url, + customHeaders = media.customHeaders, + previewUri = media.url, + contentDescription = media.description, + modifier = Modifier.fillMaxSize(), + muted = false, + showControls = true, + keepScreenOn = false, + aspectRatio = media.aspectRatio.coerceIn(0.2f, 4f), + contentScale = ContentScale.Fit, + autoPlay = false, + ) + } + } +} + +@Composable +private fun ArticleFileBlock( + block: UiArticleBlock.File, + onOpenUrl: (String) -> Unit, +) { + ElevatedCard( + onClick = { + onOpenUrl(block.url) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + FAIcon( + FontAwesomeIcons.Solid.File, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = block.name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + block.extension?.takeIf { it.isNotBlank() }?.let { + Text( + text = it.uppercase(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun ArticleEmbedBlock( + block: UiArticleBlock.Embed, + onOpenUrl: (String) -> Unit, +) { + val url = block.url + if (url == null) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + ) { + ArticleEmbedBlockContent(block = block) + } + } else { + ElevatedCard( + onClick = { + onOpenUrl(url) + }, + modifier = Modifier.fillMaxWidth(), + ) { + ArticleEmbedBlockContent(block = block) + } + } +} + +@Composable +private fun ArticleEmbedBlockContent(block: UiArticleBlock.Embed) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + block.imageUrl?.let { + NetworkImage( + model = it, + contentDescription = block.title, + modifier = + Modifier + .size(64.dp) + .clip(MaterialTheme.shapes.small), + ) + } ?: FAIcon( + FontAwesomeIcons.Solid.Globe, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = block.title ?: block.url ?: block.htmlFallback.orEmpty(), + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + block.description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + block.url?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun ArticleContentGateBlock( + block: UiArticleBlock.ContentGate, + onOpenUrl: (String) -> Unit, +) { + val description = + when (val reason = block.reason) { + is UiArticleContentGateReason.SubscriptionRequired -> { + reason.feeRequired?.let { + stringResource(R.string.article_content_gate_subscription_description_with_fee, it) + } ?: stringResource(R.string.article_content_gate_subscription_description) + } + } + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Lock, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.article_content_gate_subscription_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + block.actionUrl?.takeIf { it.isNotBlank() }?.let { url -> + FilledTonalButton( + onClick = { + onOpenUrl(url) + }, + ) { + Text(text = stringResource(R.string.rss_detail_open_in_browser)) + } + } + } + } + } +} + +@Composable +private fun ArticleLoadingContent( + contentPadding: PaddingValues, + listState: LazyListState, +) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item(key = "loading-header") { + ArticleBodyContainer { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(36.dp) + .placeholder(true), + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = + Modifier + .size(AvatarComponentDefaults.size) + .clip(MaterialTheme.shapes.medium) + .placeholder(true), + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = + Modifier + .fillMaxWidth(0.5f) + .height(14.dp) + .placeholder(true), + ) + Box( + modifier = + Modifier + .fillMaxWidth(0.35f) + .height(12.dp) + .placeholder(true), + ) + } + } + } + } + } + item(key = "loading-divider") { + ArticleBodyContainer { + HorizontalDivider() + } + } + items(5) { index -> + ArticleBodyContainer { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + repeat(if (index == 0) 4 else 3) { + Box( + modifier = + Modifier + .fillMaxWidth(if (it == 2) 0.8f else 1f) + .height(16.dp) + .placeholder(true), + ) + } + } + } + } + } +} + +@Composable +private fun ArticleBodyContainer(content: @Composable () -> Unit) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.TopCenter, + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .widthIn(max = 600.dp) + .padding(horizontal = screenHorizontalPadding), + contentAlignment = Alignment.TopStart, + ) { + content() + } + } +} + +private fun shareArticle( + context: Context, + article: UiArticle, +) { + val url = article.sourceUrl?.takeIf { it.isNotBlank() } ?: return + val title = article.title.takeIf { it.isNotBlank() } + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + title?.let { + putExtra(Intent.EXTRA_TITLE, it) + putExtra(Intent.EXTRA_SUBJECT, it) + } + putExtra(Intent.EXTRA_TEXT, url) + type = "text/plain" + } + context.startActivity(Intent.createChooser(sendIntent, title)) +} diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/EditTabDialog.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/EditTabDialog.kt index b81039c66..d003a448c 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/EditTabDialog.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/EditTabDialog.kt @@ -217,4 +217,6 @@ private val UiStrings.androidStringRes: Int UiStrings.PixivRankingDayManga -> R.string.pixiv_ranking_day_manga_title UiStrings.Illustrations -> R.string.illustrations_title UiStrings.Manga -> R.string.manga_title + UiStrings.FanboxSupported -> R.string.fanbox_supported_title + UiStrings.FanboxRecommendedCreators -> R.string.fanbox_recommended_creators_title } diff --git a/app/src/main/res/values-en-rUS/strings.xml b/app/src/main/res/values-en-rUS/strings.xml index 6f286ef3c..fc8dc5165 100644 --- a/app/src/main/res/values-en-rUS/strings.xml +++ b/app/src/main/res/values-en-rUS/strings.xml @@ -491,6 +491,9 @@ Summarizing… Translate Failed to translate + Support required + This article is only available to supporters. Open it in the browser to support the creator and read the full content. + This article is only available to supporters from ¥%1$d. Open it in the browser to support the creator and read the full content. Recent Search diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 77f539c34..132ed1ccc 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -498,6 +498,9 @@ 正在总结… 翻译 翻译失败 + 需要支持后阅读 + 这篇文章仅对支持者开放。请在浏览器中打开原页面,支持创作者后阅读全文。 + 这篇文章仅对 ¥%1$d 起的支持者开放。请在浏览器中打开原页面,支持创作者后阅读全文。 最近 搜索 主持人 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index f1beb8e97..5a81ac4e7 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -450,6 +450,9 @@ 正在摘要… 翻譯 翻譯失敗 + 需要支持後閱讀 + 這篇文章僅對支持者開放。請在瀏覽器中開啟原頁面,支持創作者後閱讀全文。 + 這篇文章僅對 ¥%1$d 起的支持者開放。請在瀏覽器中開啟原頁面,支持創作者後閱讀全文。 最近使用 搜尋 主持人 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 829645a6e..76a5d76c2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -568,6 +568,9 @@ Summarizing… Translate Failed to translate + Support required + This article is only available to supporters. Open it in the browser to support the creator and read the full content. + This article is only available to supporters from ¥%1$d. Open it in the browser to support the creator and read the full content. Recent Search @@ -705,4 +708,6 @@ Select a user to view their followers. Select a user to view profile tabs. You can also enter a user link, handle, or userKey… + Supported posts + Recommended creators diff --git a/compose-ui/src/commonMain/composeResources/values/strings.xml b/compose-ui/src/commonMain/composeResources/values/strings.xml index 69af99b3d..b346ff084 100644 --- a/compose-ui/src/commonMain/composeResources/values/strings.xml +++ b/compose-ui/src/commonMain/composeResources/values/strings.xml @@ -529,4 +529,6 @@ Manga Ranking Illustrations Manga + Supported posts + Recommended creators diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/TabIcon.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/TabIcon.kt index 960157be0..b28972251 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/TabIcon.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/TabIcon.kt @@ -27,6 +27,8 @@ import dev.dimension.flare.compose.ui.bluesky_login_username_hint import dev.dimension.flare.compose.ui.cancel_button import dev.dimension.flare.compose.ui.channel_title import dev.dimension.flare.compose.ui.dm_list_title +import dev.dimension.flare.compose.ui.fanbox_recommended_creators_title +import dev.dimension.flare.compose.ui.fanbox_supported_title import dev.dimension.flare.compose.ui.home_tab_bookmarks_title import dev.dimension.flare.compose.ui.home_tab_discover_title import dev.dimension.flare.compose.ui.home_tab_favorite_title @@ -318,4 +320,6 @@ internal val UiStrings.res: StringResource UiStrings.PixivRankingDayManga -> Res.string.pixiv_ranking_day_manga_title UiStrings.Illustrations -> Res.string.illustrations_title UiStrings.Manga -> Res.string.manga_title + UiStrings.FanboxSupported -> Res.string.fanbox_supported_title + UiStrings.FanboxRecommendedCreators -> Res.string.fanbox_recommended_creators_title } 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 9d880f1ab..4e291241a 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 @@ -102,6 +102,7 @@ public fun UiIcon.toImageVector(): ImageVector = UiIcon.Misskey -> FontAwesomeIcons.Brands.Misskey UiIcon.Bluesky -> FontAwesomeIcons.Brands.Bluesky UiIcon.Pixiv -> FontAwesomeIcons.Brands.Pixiv + UiIcon.Fanbox -> FontAwesomeIcons.Brands.Pixiv UiIcon.Nostr -> FontAwesomeIcons.Brands.Nostr UiIcon.Twitter -> FontAwesomeIcons.Brands.Twitter UiIcon.X -> FontAwesomeIcons.Brands.XTwitter diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model/PlatformTypeIcon.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model/PlatformTypeIcon.kt index c80c6b43c..f50bfd7bf 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 @@ -23,6 +23,7 @@ public val PlatformType.brandIcon: ImageVector PlatformType.Misskey -> FontAwesomeIcons.Brands.Misskey PlatformType.Bluesky -> FontAwesomeIcons.Brands.Bluesky PlatformType.Pixiv -> FontAwesomeIcons.Brands.Pixiv + PlatformType.Fanbox -> FontAwesomeIcons.Brands.Pixiv PlatformType.xQt -> FontAwesomeIcons.Brands.XTwitter PlatformType.VVo -> FontAwesomeIcons.Brands.Weibo } diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 295cd1f04..95613e1a3 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -17,6 +17,7 @@ plugins { dependencies { implementation(projects.shared) implementation(projects.social.bluesky) + implementation(projects.social.fanbox) implementation(projects.social.mastodon) implementation(projects.social.misskey) implementation(projects.social.nostr) diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index 4bb8eb595..ac5348a5d 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -639,4 +639,6 @@ Select a user to view their followers. Select a user to view profile tabs. You can also enter a user link, handle, or userKey… + Supported posts + Recommended creators 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 ef4092343..af0ed469a 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopKoinApplication.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopKoinApplication.kt @@ -2,6 +2,7 @@ package dev.dimension.flare.di import dev.dimension.flare.data.platform.AllRssTimelineLoaderFactory import dev.dimension.flare.data.platform.BlueskyPlatformSpec +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.NostrPlatformSpec @@ -33,6 +34,7 @@ internal fun runtimeData(allRssTimelineLoaderFactory: AllRssTimelineLoaderFactor MastodonPlatformSpec, MisskeyPlatformSpec, BlueskyPlatformSpec, + FanboxPlatformSpec, PixivPlatformSpec, XqtPlatformSpec, VvoPlatformSpec, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/EditTabDialog.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/EditTabDialog.kt index 07ff5b18c..263697ef1 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/EditTabDialog.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/EditTabDialog.kt @@ -33,6 +33,8 @@ import dev.dimension.flare.dm_list_title import dev.dimension.flare.edit_tab_name import dev.dimension.flare.edit_tab_name_placeholder import dev.dimension.flare.edit_tab_title +import dev.dimension.flare.fanbox_recommended_creators_title +import dev.dimension.flare.fanbox_supported_title import dev.dimension.flare.home_tab_bookmarks_title import dev.dimension.flare.home_tab_discover_title import dev.dimension.flare.home_tab_favorite_title @@ -263,4 +265,6 @@ private val UiStrings.desktopStringResource: StringResource UiStrings.PixivRankingDayManga -> Res.string.pixiv_ranking_day_manga_title UiStrings.Illustrations -> Res.string.illustrations_title UiStrings.Manga -> Res.string.manga_title + UiStrings.FanboxSupported -> Res.string.fanbox_supported_title + UiStrings.FanboxRecommendedCreators -> Res.string.fanbox_recommended_creators_title } diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentSubscriptionTools.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentSubscriptionTools.kt index 8972246ca..017187c4a 100644 --- a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentSubscriptionTools.kt +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentSubscriptionTools.kt @@ -10,8 +10,10 @@ import dev.dimension.flare.data.datasource.subscription.SubscriptionDataSource import dev.dimension.flare.data.datasource.subscription.SubscriptionSourceDetection import dev.dimension.flare.data.network.rss.DocumentData import dev.dimension.flare.data.repository.SubscriptionSourceInput +import dev.dimension.flare.ui.model.ClickEvent import dev.dimension.flare.ui.model.UiRssSource import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.route.DeeplinkRoute import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.Serializable @@ -335,7 +337,7 @@ internal class LoadRssArticleTool( val document = dataSource.loadRssArticle( url = url, - descriptionHtml = feed?.descriptionHtml?.takeIf { feed.displayMode == RssDisplayMode.DESCRIPTION_ONLY }, + descriptionHtml = feed?.descriptionHtml?.takeIf { feed.agentRssDisplayMode() == RssDisplayMode.DESCRIPTION_ONLY }, descriptionTitle = feed?.title, ) return document.toRssArticleToolText( @@ -767,7 +769,7 @@ private fun UiTimelineV2.Feed.toSubscriptionFeedToolText(): String = appendLine("sourceName: ${source.name}") appendLine("sourceIcon: ${source.icon.orEmpty()}") appendLine("createdAt: ${createdAt.value}") - appendLine("displayMode: ${displayMode.name}") + appendLine("displayMode: ${agentRssDisplayMode().name}") appendLine("description: ${description.orEmpty().take(MAX_SUBSCRIPTION_ITEM_TEXT_LENGTH)}") media?.let { image -> appendLine("imageUrl: ${image.url}") @@ -872,6 +874,33 @@ private fun UiTimelineV2.Feed.agentRssArticleRef(): String = url private fun UiTimelineV2.Feed.agentRssArticleMarker(): String = "[[rss:${agentRssArticleRef()}]]" +private fun UiTimelineV2.Feed.agentRssDisplayMode(): RssDisplayMode = + when (val event = clickEvent) { + is ClickEvent.Deeplink -> { + when (val route = DeeplinkRoute.parse(event.url)) { + is DeeplinkRoute.OpenLinkDirectly -> { + RssDisplayMode.OPEN_IN_BROWSER + } + + is DeeplinkRoute.Rss.Detail -> { + if (route.descriptionHtml != null || route.title != null) { + RssDisplayMode.DESCRIPTION_ONLY + } else { + RssDisplayMode.FULL_CONTENT + } + } + + else -> { + RssDisplayMode.FULL_CONTENT + } + } + } + + ClickEvent.Noop -> { + RssDisplayMode.FULL_CONTENT + } + } + private fun String.normalizedSubscriptionRef(): String = trim() .removePrefix("[[rss:") 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 b86f1caa2..2bd0cb62d 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 @@ -45,6 +45,7 @@ import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.presenter.compose.ComposeStatus import dev.dimension.flare.ui.render.toUi import dev.dimension.flare.ui.render.toUiPlainText +import dev.dimension.flare.ui.route.DeeplinkRoute import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.test.runTest @@ -989,7 +990,6 @@ internal class AgentToolsTest { url = "https://example.com/article", title = "Article title", descriptionHtml = "

RSS description

", - displayMode = RssDisplayMode.DESCRIPTION_ONLY, ) val dataSource = StubSubscriptionDataSource( @@ -1356,7 +1356,6 @@ private fun createFeed( url: String, title: String, descriptionHtml: String?, - displayMode: RssDisplayMode, ): UiTimelineV2.Feed = UiTimelineV2.Feed( title = title, @@ -1365,9 +1364,15 @@ private fun createFeed( url = url, createdAt = Clock.System.now().toUi(), source = UiTimelineV2.Feed.Source(name = "Example RSS", icon = null), - displayMode = displayMode, media = null, - clickEvent = ClickEvent.Noop, + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.Rss.Detail( + url = url, + descriptionHtml = descriptionHtml, + title = title, + ), + ), accountType = AccountType.Guest, ) diff --git a/feature/subscription/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Rss.kt b/feature/subscription/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Rss.kt index dd332de24..6fff95478 100644 --- a/feature/subscription/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Rss.kt +++ b/feature/subscription/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Rss.kt @@ -4,9 +4,11 @@ import com.fleeksoft.ksoup.Ksoup import dev.dimension.flare.data.database.app.model.RssDisplayMode import dev.dimension.flare.data.network.rss.model.Feed import dev.dimension.flare.model.AccountType +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.render.toUi +import dev.dimension.flare.ui.route.DeeplinkRoute import io.ktor.http.Url import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toPersistentList @@ -78,7 +80,12 @@ internal fun Feed.Atom.Entry.render( ), ) }, - displayMode = displayMode, + clickEvent = + displayMode.toFeedClickEvent( + url = link, + descriptionHtml = rawHtml, + title = title?.value, + ), createdAt = (published ?: updated) ?.let { input -> parseRssDateToInstant(input) } @@ -93,6 +100,7 @@ internal fun Feed.Rss20.Item.render( displayMode: RssDisplayMode, sourceLanguage: String? = null, ): UiTimelineV2 { + val url = link.replace("http://", "https://") val descHtml = description?.let { Ksoup.parse(it) @@ -107,7 +115,7 @@ internal fun Feed.Rss20.Item.render( title = title, description = descHtml?.text(), descriptionHtml = description, - url = link.replace("http://", "https://"), + url = url, sourceLanguages = listOfNotNull(sourceLanguage).toPersistentList(), source = UiTimelineV2.Feed.Source( @@ -129,7 +137,12 @@ internal fun Feed.Rss20.Item.render( ), ) }, - displayMode = displayMode, + clickEvent = + displayMode.toFeedClickEvent( + url = url, + descriptionHtml = description, + title = title, + ), createdAt = (pubDate ?: dcDate) ?.let { input -> parseRssDateToInstant(input) } @@ -144,6 +157,7 @@ internal fun Feed.RDF.Item.render( displayMode: RssDisplayMode, sourceLanguage: String? = null, ): UiTimelineV2 { + val url = link.replace("http://", "https://") val descHtml = description?.let { Ksoup.parse(it) @@ -153,7 +167,7 @@ internal fun Feed.RDF.Item.render( title = title, description = descHtml?.text(), descriptionHtml = description, - url = link.replace("http://", "https://"), + url = url, sourceLanguages = listOfNotNull(sourceLanguage).toPersistentList(), source = UiTimelineV2.Feed.Source( @@ -175,7 +189,12 @@ internal fun Feed.RDF.Item.render( ), ) }, - displayMode = displayMode, + clickEvent = + displayMode.toFeedClickEvent( + url = url, + descriptionHtml = description, + title = title, + ), createdAt = date ?.let { input -> parseRssDateToInstant(input) } @@ -184,6 +203,27 @@ internal fun Feed.RDF.Item.render( ) } +private fun RssDisplayMode.toFeedClickEvent( + url: String, + descriptionHtml: String?, + title: String?, +): ClickEvent = + ClickEvent.Deeplink( + when (this) { + RssDisplayMode.OPEN_IN_BROWSER -> { + DeeplinkRoute.OpenLinkDirectly(url) + } + + RssDisplayMode.FULL_CONTENT -> { + DeeplinkRoute.Rss.Detail(url) + } + + RssDisplayMode.DESCRIPTION_ONLY -> { + DeeplinkRoute.Rss.Detail(url, descriptionHtml, title) + } + }, + ) + internal fun parseRssDateToInstant(input: String): Instant? = runCatching { Instant.parse(input) diff --git a/ios-shared/build.gradle.kts b/ios-shared/build.gradle.kts index c02e3a55a..42b95bda2 100644 --- a/ios-shared/build.gradle.kts +++ b/ios-shared/build.gradle.kts @@ -27,6 +27,7 @@ kotlin { isStatic = true export(projects.shared) export(projects.social.bluesky) + export(projects.social.fanbox) export(projects.social.mastodon) export(projects.social.misskey) export(projects.social.nostr) @@ -46,6 +47,7 @@ kotlin { dependencies { api(projects.shared) api(projects.social.bluesky) + api(projects.social.fanbox) api(projects.social.mastodon) api(projects.social.misskey) api(projects.social.nostr) diff --git a/ios-shared/src/iosMain/kotlin/dev/dimension/flare/ios/shared/IosSharedHelper.kt b/ios-shared/src/iosMain/kotlin/dev/dimension/flare/ios/shared/IosSharedHelper.kt index 6d733b9f3..122c339cd 100644 --- a/ios-shared/src/iosMain/kotlin/dev/dimension/flare/ios/shared/IosSharedHelper.kt +++ b/ios-shared/src/iosMain/kotlin/dev/dimension/flare/ios/shared/IosSharedHelper.kt @@ -5,6 +5,7 @@ import dev.dimension.flare.common.Message import dev.dimension.flare.common.SwiftOnDeviceAI import dev.dimension.flare.data.platform.AllRssTimelineLoaderFactory import dev.dimension.flare.data.platform.BlueskyPlatformSpec +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.NostrPlatformSpec @@ -63,6 +64,7 @@ internal fun runtimeData(allRssTimelineLoaderFactory: AllRssTimelineLoaderFactor MastodonPlatformSpec, MisskeyPlatformSpec, BlueskyPlatformSpec, + FanboxPlatformSpec, PixivPlatformSpec, XqtPlatformSpec, VvoPlatformSpec, diff --git a/iosApp/flare/UI/Component/Status/StatusActionView.swift b/iosApp/flare/UI/Component/Status/StatusActionView.swift index 263a9b46f..9a90b0ff9 100644 --- a/iosApp/flare/UI/Component/Status/StatusActionView.swift +++ b/iosApp/flare/UI/Component/Status/StatusActionView.swift @@ -348,6 +348,7 @@ extension UiIcon { case .weibo: return "fa-weibo" case .translate: return "fa-language" case .pixiv: return "fa-pixiv" + case .fanbox: return "fa-pixiv" } } } diff --git a/iosApp/flare/UI/Component/Status/StatusView.swift b/iosApp/flare/UI/Component/Status/StatusView.swift index 518d9e2cf..f48a153a0 100644 --- a/iosApp/flare/UI/Component/Status/StatusView.swift +++ b/iosApp/flare/UI/Component/Status/StatusView.swift @@ -290,6 +290,10 @@ struct StatusView: View { Image("fa-pixiv") .font(.caption) .foregroundStyle(.secondary) + case .fanbox: + Image("fa-pixiv") + .font(.caption) + .foregroundStyle(.secondary) } } if !isDetail { diff --git a/iosApp/flare/UI/Component/TabIcon.swift b/iosApp/flare/UI/Component/TabIcon.swift index 58dd4357e..926d02fc0 100644 --- a/iosApp/flare/UI/Component/TabIcon.swift +++ b/iosApp/flare/UI/Component/TabIcon.swift @@ -77,6 +77,8 @@ extension UiStrings { case .pixivRankingDayManga: String(localized: "pixiv_ranking_day_manga_title", defaultValue: "Manga Ranking") case .illustrations: String(localized: "illustrations_title", defaultValue: "Illustrations") case .manga: String(localized: "manga_title", defaultValue: "Manga") + case .fanboxSupported: String(localized: "fanbox_supported_title", defaultValue: "Supported posts") + case .fanboxRecommendedCreators: String(localized: "fanbox_recommended_creators_title", defaultValue: "Recommended creators") } } } diff --git a/iosApp/flare/UI/Screen/ComposeScreen.swift b/iosApp/flare/UI/Screen/ComposeScreen.swift index b74e6b3b4..3a89a081b 100644 --- a/iosApp/flare/UI/Screen/ComposeScreen.swift +++ b/iosApp/flare/UI/Screen/ComposeScreen.swift @@ -956,6 +956,8 @@ private extension UiProfile { return .nostr case .pixiv: return .pixiv + case .fanbox: + return .fanbox case .xQt: return .x case .vvo: diff --git a/iosApp/flare/UI/Screen/ServiceSelectionScreen.swift b/iosApp/flare/UI/Screen/ServiceSelectionScreen.swift index 6e4526c17..698a80eb8 100644 --- a/iosApp/flare/UI/Screen/ServiceSelectionScreen.swift +++ b/iosApp/flare/UI/Screen/ServiceSelectionScreen.swift @@ -280,6 +280,8 @@ struct ServiceSelectionScreen: View { return "Weibo" case .pixiv: return "Pixiv" + case .fanbox: + return "FANBOX" } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 2b0e1fc9d..9009dee22 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,6 +25,7 @@ rootProject.name = "Flare" include(":app") include(":shared") include(":social:bluesky") +include(":social:fanbox") include(":social:mastodon") include(":social:misskey") include(":social:nostr") 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 e4257dd98..9d7c84eee 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 @@ -5,6 +5,7 @@ import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.mapper.blueskyBookmark import dev.dimension.flare.ui.model.mapper.blueskyLike import dev.dimension.flare.ui.model.mapper.blueskyReblog +import dev.dimension.flare.ui.model.mapper.fanboxLike import dev.dimension.flare.ui.model.mapper.mastodonBookmark import dev.dimension.flare.ui.model.mapper.mastodonLike import dev.dimension.flare.ui.model.mapper.mastodonRepost @@ -452,6 +453,26 @@ public sealed interface PostEvent { ) } } + + @Serializable + public sealed interface Fanbox : PostEvent { + @Serializable + public data class Like( + public override val postKey: MicroBlogKey, + public val liked: Boolean, + public val count: Long = 0, + public val accountKey: MicroBlogKey, + ) : Fanbox, + UpdatePostActionMenuEvent { + public override fun nextActionMenu(): ActionMenu.Item = + ActionMenu.fanboxLike( + statusKey = postKey, + liked = true, + count = (count + if (!liked) 1 else 0).coerceAtLeast(0), + accountKey = accountKey, + ) + } + } } @HiddenFromObjC diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/datasource/ArticleDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/datasource/ArticleDataSource.kt new file mode 100644 index 000000000..471d35f4b --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/datasource/ArticleDataSource.kt @@ -0,0 +1,10 @@ +package dev.dimension.flare.data.datasource.microblog.datasource + +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiArticle +import kotlin.native.HiddenFromObjC + +@HiddenFromObjC +public interface ArticleDataSource { + public suspend fun article(articleKey: MicroBlogKey): UiArticle +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/tab/TimelineSpecIds.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/tab/TimelineSpecIds.kt index 920000822..7bd0ab404 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/tab/TimelineSpecIds.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/tab/TimelineSpecIds.kt @@ -38,6 +38,8 @@ public object TimelineSpecIds { public const val PIXIV_FOLLOWING: String = "pixiv.following" public const val PIXIV_BOOKMARK: String = "pixiv.bookmark" + public const val FANBOX_SUPPORTED: String = "fanbox.supported" + public val legacyMigrationIds: Set = setOf( COMMON_HOME, @@ -65,5 +67,6 @@ public object TimelineSpecIds { VVO_LIKED, PIXIV_FOLLOWING, PIXIV_BOOKMARK, + FANBOX_SUPPORTED, ) } 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 ca276c289..fd42f36c9 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformType.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/model/PlatformType.kt @@ -19,6 +19,7 @@ public enum class PlatformType { VVo, Nostr, + Fanbox, } @Immutable diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiArticle.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiArticle.kt new file mode 100644 index 000000000..4b3832702 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiArticle.kt @@ -0,0 +1,177 @@ +package dev.dimension.flare.ui.model + +import androidx.compose.runtime.Immutable +import dev.dimension.flare.common.SerializableImmutableList +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.render.RenderContent +import dev.dimension.flare.ui.render.UiDateTime +import dev.dimension.flare.ui.render.UiRichText +import dev.dimension.flare.ui.render.plainText +import dev.dimension.flare.ui.render.uiRichTextOf +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@Immutable +public data class UiArticle public constructor( + val key: String, + val title: String, + val content: UiArticleContent, + val cover: UiMedia.Image? = null, + val publishDate: UiDateTime? = null, + val author: UiArticleAuthor? = null, + val sourceUrl: String? = null, +) + +@Serializable +@Immutable +public sealed interface UiArticleAuthor { + @Serializable + @SerialName("profile") + @Immutable + public data class Profile public constructor( + val profile: UiProfile, + ) : UiArticleAuthor + + @Serializable + @SerialName("rss") + @Immutable + public data class Rss public constructor( + val siteName: String? = null, + val byline: String? = null, + val iconUrl: String? = null, + ) : UiArticleAuthor +} + +@Serializable +@Immutable +public data class UiArticleContent public constructor( + val blocks: SerializableImmutableList = persistentListOf(), + val rawText: String = "", +) { + val isEmpty: Boolean + get() = blocks.isEmpty() +} + +@Serializable +@Immutable +public sealed interface UiArticleBlock { + public val key: String + + @Serializable + @SerialName("text") + @Immutable + public data class Text public constructor( + override val key: String, + val content: RenderContent.Text, + ) : UiArticleBlock { + public val richText: UiRichText by lazy { + uiRichTextOf(listOf(content)) + } + } + + @Serializable + @SerialName("image") + @Immutable + public data class Image public constructor( + override val key: String, + val media: UiMedia.Image, + ) : UiArticleBlock + + @Serializable + @SerialName("video") + @Immutable + public data class Video public constructor( + override val key: String, + val media: UiMedia.Video, + ) : UiArticleBlock + + @Serializable + @SerialName("file") + @Immutable + public data class File public constructor( + override val key: String, + val name: String, + val url: String, + val sizeBytes: Long? = null, + val extension: String? = null, + ) : UiArticleBlock + + @Serializable + @SerialName("embed") + @Immutable + public data class Embed public constructor( + override val key: String, + val url: String? = null, + val title: String? = null, + val description: String? = null, + val imageUrl: String? = null, + val htmlFallback: String? = null, + ) : UiArticleBlock + + @Serializable + @SerialName("content_gate") + @Immutable + public data class ContentGate public constructor( + override val key: String, + val reason: UiArticleContentGateReason, + val actionUrl: String? = null, + ) : UiArticleBlock +} + +@Serializable +@Immutable +public sealed interface UiArticleContentGateReason { + @Serializable + @SerialName("subscription_required") + @Immutable + public data class SubscriptionRequired public constructor( + val platformType: PlatformType, + val feeRequired: Int? = null, + ) : UiArticleContentGateReason +} + +public fun uiArticleContentOf( + blocks: List, + rawText: String? = null, +): UiArticleContent = + UiArticleContent( + blocks = blocks.toImmutableList(), + rawText = + rawText + ?: blocks + .map { it.plainText() } + .filter { it.isNotBlank() } + .joinToString(separator = "\n") + .trim(), + ) + +public fun UiArticleBlock.plainText(): String = + when (this) { + is UiArticleBlock.Text -> { + content.plainText() + } + + is UiArticleBlock.Image -> { + media.description.orEmpty() + } + + is UiArticleBlock.Video -> { + "" + } + + is UiArticleBlock.File -> { + name + } + + is UiArticleBlock.Embed -> { + listOfNotNull(title, description, url) + .joinToString(separator = "\n") + } + + is UiArticleBlock.ContentGate -> { + "" + } + } 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 293f1d48e..d2b8ba087 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 @@ -63,6 +63,7 @@ public enum class UiIcon { Translate, UnFavourite, Pixiv, + Fanbox, } /** diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiStrings.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiStrings.kt index 01e8fea0f..52465d493 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiStrings.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiStrings.kt @@ -54,6 +54,8 @@ public enum class UiStrings { Following, PostsWithReplies, Media, + FanboxSupported, + FanboxRecommendedCreators, } public fun UiStrings.asText(): UiText = UiText.Localized(this) 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 dbc389d5e..d5b392d0c 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 @@ -2,7 +2,6 @@ package dev.dimension.flare.ui.model import androidx.compose.runtime.Immutable import dev.dimension.flare.common.SerializableImmutableList -import dev.dimension.flare.data.database.app.model.RssDisplayMode import dev.dimension.flare.data.datasource.microblog.ActionMenu import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey @@ -139,22 +138,8 @@ public sealed class UiTimelineV2 { public val translationDisplayState: TranslationDisplayState = TranslationDisplayState.Hidden, override val createdAt: UiDateTime, val source: Source, - val displayMode: RssDisplayMode = RssDisplayMode.FULL_CONTENT, val media: UiMedia.Image? = null, - public val clickEvent: ClickEvent = - when (displayMode) { - RssDisplayMode.OPEN_IN_BROWSER -> { - ClickEvent.Deeplink(DeeplinkRoute.OpenLinkDirectly(url)) - } - - RssDisplayMode.FULL_CONTENT -> { - ClickEvent.Deeplink(DeeplinkRoute.Rss.Detail(url)) - } - - RssDisplayMode.DESCRIPTION_ONLY -> { - ClickEvent.Deeplink(DeeplinkRoute.Rss.Detail(url, descriptionHtml, title)) - } - }, + public val clickEvent: ClickEvent = ClickEvent.Deeplink(DeeplinkRoute.Rss.Detail(url)), override val accountType: AccountType, @Transient override val itemKey: String? = null, @@ -191,7 +176,6 @@ public sealed class UiTimelineV2 { .add(createdAt.value) .add(source.name) .add(source.icon) - .add(displayMode) .add(media?.renderSummaryHash()) .add(accountType) .build() diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/FanboxActionMenu.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/FanboxActionMenu.kt new file mode 100644 index 000000000..ee1038b75 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/FanboxActionMenu.kt @@ -0,0 +1,45 @@ +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.fanboxLike( + statusKey: MicroBlogKey, + liked: Boolean, + count: Long, + accountKey: MicroBlogKey, +): ActionMenu.Item = + ActionMenu.Item( + updateKey = "fanbox_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 = + if (liked) { + ClickEvent.Noop + } else { + ClickEvent.event( + accountKey, + PostEvent.Fanbox.Like( + postKey = statusKey, + liked = liked, + count = count, + accountKey = accountKey, + ), + ) + }, + actionFamily = PostActionFamily.Like, + ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/article/ArticlePresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/article/ArticlePresenter.kt new file mode 100644 index 000000000..c63f00aa8 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/article/ArticlePresenter.kt @@ -0,0 +1,132 @@ +package dev.dimension.flare.ui.presenter.article + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.mapper.saveToDatabase +import dev.dimension.flare.data.datasource.microblog.datasource.ArticleDataSource +import dev.dimension.flare.data.datasource.microblog.paging.TimelinePagingMapper +import dev.dimension.flare.data.repository.AccountService +import dev.dimension.flare.data.repository.STATUS_HISTORY_PAGING_KEY +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.ClickEvent +import dev.dimension.flare.ui.model.UiArticle +import dev.dimension.flare.ui.model.UiArticleAuthor +import dev.dimension.flare.ui.model.UiState +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.collectAsUiState +import dev.dimension.flare.ui.model.takeSuccess +import dev.dimension.flare.ui.presenter.PresenterBase +import dev.dimension.flare.ui.render.toUi +import dev.dimension.flare.ui.route.DeeplinkRoute +import kotlinx.coroutines.flow.map +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import kotlin.time.Clock + +public class ArticlePresenter( + private val accountType: AccountType, + private val articleKey: MicroBlogKey, +) : PresenterBase(), + KoinComponent { + private val accountService: AccountService by inject() + private val cacheDatabase: CacheDatabase by inject() + + private val articleStateFlow by lazy { + accountService + .accountServiceFlow(accountType) + .map { service -> + require(service is ArticleDataSource) + service.article(articleKey) + } + } + + @Immutable + public interface State { + public val article: UiState + } + + @Composable + override fun body(): State { + val articleState by articleStateFlow.collectAsUiState() + LaunchedEffect(accountType, articleKey, articleState) { + articleState + .takeSuccess() + ?.toHistoryFeed( + accountType = accountType, + articleKey = articleKey, + )?.let { feed -> + saveToDatabase( + database = cacheDatabase, + items = + listOf( + TimelinePagingMapper.toDb( + data = feed, + pagingKey = STATUS_HISTORY_PAGING_KEY, + sortId = Clock.System.now().toEpochMilliseconds(), + ), + ), + ) + } + } + return object : State { + override val article: UiState = articleState + } + } +} + +private fun UiArticle.toHistoryFeed( + accountType: AccountType, + articleKey: MicroBlogKey, +): UiTimelineV2.Feed? { + val url = sourceUrl?.takeIf { it.isNotBlank() } ?: return null + return UiTimelineV2.Feed( + title = title.takeIf { it.isNotBlank() }, + description = content.rawText.takeIf { it.isNotBlank() }, + url = url, + createdAt = publishDate ?: Clock.System.now().toUi(), + source = author.toHistoryFeedSource(fallbackName = url), + media = cover, + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.Article( + accountType = accountType, + articleKey = articleKey, + ), + ), + accountType = accountType, + ) +} + +private fun UiArticleAuthor?.toHistoryFeedSource(fallbackName: String): UiTimelineV2.Feed.Source = + when (this) { + is UiArticleAuthor.Profile -> { + UiTimelineV2.Feed.Source( + name = + profile.name.raw.takeIf { it.isNotBlank() } + ?: profile.handle.raw.takeIf { it.isNotBlank() } + ?: fallbackName, + icon = profile.avatar?.url, + ) + } + + is UiArticleAuthor.Rss -> { + UiTimelineV2.Feed.Source( + name = + listOfNotNull(siteName, byline) + .firstOrNull { it.isNotBlank() } + ?: fallbackName, + icon = iconUrl, + ) + } + + null -> { + UiTimelineV2.Feed.Source( + name = fallbackName, + icon = null, + ) + } + } 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 eb58a5653..f2e8629d6 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 @@ -122,6 +122,12 @@ public sealed class DeeplinkRoute { val articleId: String? = null, ) : DeeplinkRoute() + @Serializable + public data class Article( + val accountType: AccountType, + val articleKey: MicroBlogKey, + ) : DeeplinkRoute() + @Serializable public data class Search( val accountType: AccountType, diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt index 7a97c2688..ef632c57d 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt @@ -1925,7 +1925,6 @@ class MixedRemoteMediatorTest : RobolectricTest() { name = "test", icon = null, ), - displayMode = dev.dimension.flare.data.database.app.model.RssDisplayMode.FULL_CONTENT, accountType = AccountType.Guest, ) diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/ui/model/UiArticleTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/ui/model/UiArticleTest.kt new file mode 100644 index 000000000..d763eb50f --- /dev/null +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/ui/model/UiArticleTest.kt @@ -0,0 +1,132 @@ +package dev.dimension.flare.ui.model + +import dev.dimension.flare.model.PlatformType +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.toUi +import kotlinx.collections.immutable.persistentListOf +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.time.Instant + +class UiArticleTest { + @Test + fun serializes_article_content_blocks() { + val article = + UiArticle( + key = "article:1", + title = "Title", + cover = + UiMedia.Image( + url = "https://example.com/cover.jpg", + previewUrl = "https://example.com/cover.jpg", + description = null, + width = 1200f, + height = 630f, + sensitive = false, + ), + author = + UiArticleAuthor.Rss( + siteName = "Example", + byline = "Author", + iconUrl = "https://example.com/favicon.ico", + ), + sourceUrl = "https://example.com/article", + publishDate = Instant.parse("2024-01-02T03:04:05Z").toUi(), + content = + uiArticleContentOf( + blocks = + listOf( + UiArticleBlock.Text( + key = "text:0", + content = + RenderContent.Text( + runs = persistentListOf(RenderRun.Text("Hello")), + block = RenderBlockStyle(headingLevel = 1), + ), + ), + UiArticleBlock.Image( + key = "image:1", + media = + UiMedia.Image( + url = "https://example.com/image.jpg", + previewUrl = "https://example.com/image.jpg", + description = "Alt", + width = 300f, + height = 200f, + sensitive = false, + ), + ), + UiArticleBlock.Video( + key = "video:2", + media = + UiMedia.Video( + url = "https://example.com/video.mp4", + thumbnailUrl = "", + description = "Video", + width = 16f, + height = 9f, + ), + ), + UiArticleBlock.File( + key = "file:3", + name = "sample.pdf", + url = "https://example.com/sample.pdf", + sizeBytes = 1024L, + extension = "pdf", + ), + UiArticleBlock.Embed( + key = "embed:4", + url = "https://example.com/embed", + title = "Embed", + description = "Description", + ), + UiArticleBlock.ContentGate( + key = "gate:5", + reason = + UiArticleContentGateReason.SubscriptionRequired( + platformType = PlatformType.Fanbox, + feeRequired = 500, + ), + actionUrl = "https://example.com/support", + ), + ), + ), + ) + + val decoded = Json.decodeFromString(Json.encodeToString(article)) + + assertEquals(article.key, decoded.key) + assertEquals("https://example.com/cover.jpg", decoded.cover?.url) + assertEquals(1200f / 630f, decoded.cover?.aspectRatio) + assertEquals(Instant.parse("2024-01-02T03:04:05Z"), decoded.publishDate?.value) + assertEquals("https://example.com/article", decoded.sourceUrl) + assertEquals("Hello\nAlt\nsample.pdf\nEmbed\nDescription\nhttps://example.com/embed", decoded.content.rawText) + val author = assertIs(decoded.author) + assertEquals("Example", author.siteName) + assertEquals("Author", author.byline) + + val text = assertIs(decoded.content.blocks[0]) + assertEquals(1, text.content.block.headingLevel) + assertEquals("Hello", assertIs(text.content.runs.single()).text) + + val image = assertIs(decoded.content.blocks[1]) + assertEquals(1.5f, image.media.aspectRatio) + assertEquals("Alt", image.media.description) + + val video = assertIs(decoded.content.blocks[2]) + assertEquals(16f / 9f, video.media.aspectRatio) + assertEquals("https://example.com/video.mp4", video.media.url) + + val gate = assertIs(decoded.content.blocks[5]) + val reason = assertIs(gate.reason) + assertEquals(PlatformType.Fanbox, reason.platformType) + assertEquals(500, reason.feeRequired) + assertEquals("https://example.com/support", gate.actionUrl) + } +} diff --git a/social/fanbox/build.gradle.kts b/social/fanbox/build.gradle.kts new file mode 100644 index 000000000..d2c371078 --- /dev/null +++ b/social/fanbox/build.gradle.kts @@ -0,0 +1,76 @@ +import dev.dimension.flare.buildlogic.FlarePlatform +import dev.dimension.flare.buildlogic.flare + +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) +} + +kotlin { + flare { + namespace = "dev.dimension.flare.social.fanbox" + 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(libs.bundles.ktorfit) + implementation(libs.bundles.ktor) + implementation(libs.ktor.client.resources) + implementation(libs.ksoup) + implementation(dependencies.platform(libs.koin.bom)) + implementation(libs.koin.core) + implementation(libs.paging.common) + } + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + 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) + } + } + } +} + +ktorfit { + compilerPluginVersion.set("2.3.4") +} + +afterEvaluate { + val runKtlintFormatOverCommonMainSourceSet by tasks + val runKtlintCheckOverCommonMainSourceSet by tasks + runKtlintFormatOverCommonMainSourceSet.dependsOn("kspCommonMainKotlinMetadata") + runKtlintCheckOverCommonMainSourceSet.dependsOn("kspCommonMainKotlinMetadata") + tasks { + configureEach { + if (this.name != "kspCommonMainKotlinMetadata" && this.name.startsWith("ksp")) { + this.dependsOn("kspCommonMainKotlinMetadata") + } + } + } +} diff --git a/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/datasource/fanbox/FanboxDataSource.kt b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/datasource/fanbox/FanboxDataSource.kt new file mode 100644 index 000000000..95232379a --- /dev/null +++ b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/datasource/fanbox/FanboxDataSource.kt @@ -0,0 +1,268 @@ +package dev.dimension.flare.data.datasource.fanbox + +import dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataSource +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.ArticleDataSource +import dev.dimension.flare.data.datasource.microblog.datasource.PinnableTimelineTabDataSource +import dev.dimension.flare.data.datasource.microblog.datasource.PinnableTimelineTabSection +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.tab.ShortcutSpec +import dev.dimension.flare.data.model.tab.TimelineCandidate +import dev.dimension.flare.data.model.tab.TimelineSpec +import dev.dimension.flare.data.network.fanbox.FanboxPostIdRequest +import dev.dimension.flare.data.network.fanbox.FanboxService +import dev.dimension.flare.data.network.fanbox.requireCsrfToken +import dev.dimension.flare.data.platform.CommonTimelineSpecs +import dev.dimension.flare.data.platform.FANBOX_WEB_HOST +import dev.dimension.flare.data.platform.FanboxCredential +import dev.dimension.flare.data.platform.FanboxPlatformSpec +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiArticle +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 kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.Flow + +internal class FanboxDataSource( + override val accountKey: MicroBlogKey, + private val credentialFlow: Flow, + private val updateCredential: suspend (FanboxCredential) -> Unit, +) : AuthenticatedMicroblogDataSource, + PinnableTimelineTabDataSource, + TimelineTabConfigurationDataSource, + ArticleDataSource, + UserDataSource, + PostDataSource, + RelationDataSource, + PostEventHandler.Handler { + private val service = + FanboxService( + credentialFlow = credentialFlow, + onCredentialRefreshed = updateCredential, + ) + private val loader by lazy { + FanboxLoader( + accountKey = accountKey, + service = service, + ) + } + + override val userHandler by lazy { + UserHandler( + host = accountKey.host, + loader = loader, + ) + } + + override val postHandler by lazy { + PostHandler( + accountType = AccountType.Specific(accountKey), + loader = loader, + ) + } + + override val postEventHandler by lazy { + PostEventHandler( + accountType = AccountType.Specific(accountKey), + handler = this, + ) + } + + override val relationHandler by lazy { + RelationHandler( + accountType = AccountType.Specific(accountKey), + dataSource = loader, + ) + } + + override val supportedRelationTypes: Set = loader.supportedTypes + + override val pinnableTimelineTabs: List = emptyList() + + override val defaultTabs: ImmutableList> by lazy { + persistentListOf( + CommonTimelineSpecs.home.candidate( + data = TimelineSpec.AccountBasedData(accountKey), + icon = IconType.FavIcon("https://$FANBOX_WEB_HOST/"), + title = UiText.Raw("FANBOX"), + ), + ) + } + + override val builtInTimelineTabs: ImmutableList> by lazy { + persistentListOf( + CommonTimelineSpecs.home.candidate( + data = TimelineSpec.AccountBasedData(accountKey), + icon = IconType.Material(UiIcon.Fanbox), + ), + CommonTimelineSpecs.discover.candidate( + data = TimelineSpec.AccountBasedData(accountKey), + icon = IconType.Material(UiIcon.Fanbox), + ), + FanboxPlatformSpec.supportedTimelineSpec.candidate( + data = TimelineSpec.AccountBasedData(accountKey), + ), + ) + } + + override val shortcuts: ImmutableList by lazy { + persistentListOf( + ShortcutSpec( + title = UiStrings.Home, + icon = UiIcon.Home, + target = + ShortcutSpec.Target.Timeline( + CommonTimelineSpecs.home.candidate( + data = TimelineSpec.AccountBasedData(accountKey), + icon = IconType.Material(UiIcon.Fanbox), + ), + ), + ), + ShortcutSpec( + title = UiStrings.FanboxRecommendedCreators, + icon = UiIcon.Featured, + target = + ShortcutSpec.Target.Timeline( + CommonTimelineSpecs.discover.candidate( + data = TimelineSpec.AccountBasedData(accountKey), + icon = IconType.Material(UiIcon.Fanbox), + ), + ), + ), + ShortcutSpec( + title = UiStrings.FanboxSupported, + icon = UiIcon.Heart, + target = + ShortcutSpec.Target.Timeline( + FanboxPlatformSpec.supportedTimelineSpec.candidate( + data = TimelineSpec.AccountBasedData(accountKey), + ), + ), + ), + ) + } + + override fun homeTimeline(): RemoteLoader = + FanboxHomeTimelineLoader( + service = service, + accountKey = accountKey, + ) + + override fun userTimeline( + userKey: MicroBlogKey, + mediaOnly: Boolean, + ): RemoteLoader = + FanboxCreatorTimelineLoader( + service = service, + accountKey = accountKey, + creatorKey = userKey, + ) + + override fun context(statusKey: MicroBlogKey): RemoteLoader = + FanboxStatusDetailLoader( + service = service, + accountKey = accountKey, + statusKey = statusKey, + ) + + override fun searchStatus(query: String): RemoteLoader = + FanboxSearchTimelineLoader( + service = service, + accountKey = accountKey, + query = query, + ) + + override fun searchUser(query: String): RemoteLoader = + FanboxSearchCreatorLoader( + service = service, + accountKey = accountKey, + query = query, + ) + + override fun discoverUsers(): RemoteLoader = + FanboxRecommendedCreatorLoader( + service = service, + accountKey = accountKey, + ) + + override fun discoverStatuses(): RemoteLoader = + FanboxRecommendedCreatorsTimelineLoader( + service = service, + accountKey = accountKey, + ) + + override fun discoverHashtags(): RemoteLoader = notSupported() + + override fun following(userKey: MicroBlogKey): RemoteLoader = + FanboxFollowingCreatorLoader( + service = service, + accountKey = accountKey, + ) + + override fun fans(userKey: MicroBlogKey): RemoteLoader = notSupported() + + fun supportedTimelineLoader(): RemoteLoader = + FanboxSupportedTimelineLoader( + service = service, + accountKey = accountKey, + ) + + override suspend fun article(articleKey: MicroBlogKey): UiArticle = + service + .postInfo(postId = articleKey.id) + .body + .toUiArticle( + accountKey = accountKey, + imageHeaders = service.fanboxImageHeaders(), + ) + + override fun profileTabs(userKey: MicroBlogKey): ImmutableList = + persistentListOf( + ProfileTab( + name = UiStrings.Posts, + loader = + FanboxCreatorTimelineLoader( + service = service, + accountKey = accountKey, + creatorKey = userKey, + ), + ), + ) + + override suspend fun handle( + event: PostEvent, + updater: DatabaseUpdater, + ) { + require(event is PostEvent.Fanbox) + when (event) { + is PostEvent.Fanbox.Like -> { + if (!event.liked) { + val credential = service.credentialWithCsrf() + service.likePost( + csrfToken = credential.requireCsrfToken(), + request = FanboxPostIdRequest(event.postKey.id), + ) + } + } + } + } +} diff --git a/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/datasource/fanbox/FanboxMapper.kt b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/datasource/fanbox/FanboxMapper.kt new file mode 100644 index 000000000..ae7117c26 --- /dev/null +++ b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/datasource/fanbox/FanboxMapper.kt @@ -0,0 +1,560 @@ +package dev.dimension.flare.data.datasource.fanbox + +import dev.dimension.flare.data.datasource.microblog.ActionMenu +import dev.dimension.flare.data.datasource.microblog.PostActionFamily +import dev.dimension.flare.data.network.fanbox.FANBOX_USER_AGENT +import dev.dimension.flare.data.network.fanbox.FANBOX_WEB_URL +import dev.dimension.flare.data.network.fanbox.FanboxCommentItem +import dev.dimension.flare.data.network.fanbox.FanboxCreatorDetailBody +import dev.dimension.flare.data.network.fanbox.FanboxPostDetailBody +import dev.dimension.flare.data.network.fanbox.FanboxPostEntity +import dev.dimension.flare.data.network.fanbox.FanboxService +import dev.dimension.flare.data.network.fanbox.FanboxUserEntity +import dev.dimension.flare.data.platform.FANBOX_HOST +import dev.dimension.flare.data.platform.FANBOX_WEB_HOST +import dev.dimension.flare.data.platform.FanboxCredential +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.UiArticle +import dev.dimension.flare.ui.model.UiArticleAuthor +import dev.dimension.flare.ui.model.UiArticleBlock +import dev.dimension.flare.ui.model.UiArticleContentGateReason +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.toUiImage +import dev.dimension.flare.ui.model.uiArticleContentOf +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.toUi +import dev.dimension.flare.ui.render.toUiPlainText +import dev.dimension.flare.ui.route.DeeplinkRoute +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentMap +import kotlin.time.Clock +import kotlin.time.Instant + +internal fun fanboxPostKey(id: String): MicroBlogKey = MicroBlogKey(id = id, host = FANBOX_HOST) + +internal fun fanboxCreatorKey(id: String): MicroBlogKey = MicroBlogKey(id = id, host = FANBOX_HOST) + +internal fun fanboxCommentKey( + postKey: MicroBlogKey, + id: String, +): MicroBlogKey = MicroBlogKey(id = "${postKey.id}:comment:$id", host = FANBOX_HOST) + +private val FANBOX_ARTICLE_VIDEO_EXTENSIONS = setOf("mp4", "webm", "mov", "m4v") + +internal suspend fun FanboxService.fanboxImageHeaders(): ImmutableMap = currentCredential().toFanboxImageHeaders() + +internal fun FanboxCredential.toFanboxImageHeaders(): ImmutableMap = + buildMap { + put("Origin", "https://$FANBOX_WEB_HOST") + put("Referer", FANBOX_WEB_URL) + put("User-Agent", FANBOX_USER_AGENT) + sessionId.takeIf { it.isNotBlank() }?.let { + put("Cookie", "FANBOXSESSID=$it") + } + }.toPersistentMap() + +internal fun FanboxPostEntity.toUiTimeline( + accountKey: MicroBlogKey, + imageHeaders: ImmutableMap? = null, +): UiTimelineV2.Feed { + val articleKey = fanboxPostKey(id) + return UiTimelineV2.Feed( + title = title, + description = excerpt.takeIf { it.isNotBlank() }, + url = fanboxPostUrl(creatorId, id), + createdAt = parseFanboxInstant(publishedDatetime).toUi(), + source = + UiTimelineV2.Feed.Source( + name = user?.name?.takeIf { it.isNotBlank() } ?: creatorId, + icon = user?.iconUrl, + ), + media = cover?.url.toArticleCover(hasAdultContent, imageHeaders), + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.Article( + accountType = AccountType.Specific(accountKey), + articleKey = articleKey, + ), + ), + accountType = AccountType.Specific(accountKey), + ) +} + +internal fun FanboxPostDetailBody.toUiTimeline( + accountKey: MicroBlogKey, + imageHeaders: ImmutableMap? = null, +): UiTimelineV2.Feed { + val articleKey = fanboxPostKey(id) + return UiTimelineV2.Feed( + title = title, + description = excerpt.takeIf { it.isNotBlank() }, + url = fanboxPostUrl(creatorId, id), + createdAt = parseFanboxInstant(publishedDatetime).toUi(), + source = + UiTimelineV2.Feed.Source( + name = user?.name?.takeIf { it.isNotBlank() } ?: creatorId, + icon = user?.iconUrl, + ), + media = toArticleCover(imageHeaders), + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.Article( + accountType = AccountType.Specific(accountKey), + articleKey = articleKey, + ), + ), + accountType = AccountType.Specific(accountKey), + ) +} + +internal fun FanboxPostDetailBody.toUiArticle( + accountKey: MicroBlogKey, + imageHeaders: ImmutableMap? = null, +): UiArticle { + val articleKey = fanboxPostKey(id) + val author = user?.toUiProfile(accountKey, creatorId, imageHeaders) + val sourceUrl = fanboxPostUrl(creatorId, id) + val blocks = + buildList { + addAll( + body + ?.toArticleBlocks( + postId = id, + sensitive = hasAdultContent, + imageHeaders = imageHeaders, + ).orEmpty() + .ifEmpty { + excerpt + .takeIf { it.isNotBlank() } + ?.toArticleTextBlock(key = "$id:excerpt") + ?.let { listOf(it) } + .orEmpty() + }, + ) + if (isRestricted) { + add( + UiArticleBlock.ContentGate( + key = "$id:content-gate", + reason = + UiArticleContentGateReason.SubscriptionRequired( + platformType = PlatformType.Fanbox, + feeRequired = feeRequired.takeIf { it > 0 }, + ), + actionUrl = sourceUrl, + ), + ) + } + } + return UiArticle( + key = articleKey.toString(), + title = title, + content = uiArticleContentOf(blocks), + cover = toArticleCover(imageHeaders), + publishDate = parseFanboxInstant(publishedDatetime).toUi(), + author = author?.let { UiArticleAuthor.Profile(it) }, + sourceUrl = sourceUrl, + ) +} + +internal fun FanboxCommentItem.toUiTimeline( + accountKey: MicroBlogKey, + postKey: MicroBlogKey, + imageHeaders: ImmutableMap? = null, +): UiTimelineV2.Post { + val statusKey = fanboxCommentKey(postKey, id) + return UiTimelineV2.Post( + platformType = PlatformType.Fanbox, + images = persistentListOf(), + sensitive = false, + contentWarning = null, + user = user?.toUiProfile(accountKey, creatorId = null, imageHeaders), + content = body.toUiPlainText(), + actions = + persistentListOf( + ActionMenu.Item( + icon = if (isLiked) UiIcon.Unlike else UiIcon.Like, + count = UiNumber(likeCount.toLong()), + color = if (isLiked) ActionMenu.Item.Color.Red else null, + clickEvent = ClickEvent.Noop, + actionFamily = PostActionFamily.Like, + ), + ), + poll = null, + statusKey = statusKey, + card = null, + createdAt = parseFanboxInstant(createdDatetime).toUi(), + visibility = null, + clickEvent = user?.let { ClickEvent.Deeplink(it.profileRoute(accountKey, null)) } ?: ClickEvent.Noop, + mediaClickPolicy = UiTimelineV2.Post.MediaClickPolicy.OpenPostClickEvent, + accountType = AccountType.Specific(accountKey), + ) +} + +internal fun FanboxCreatorDetailBody.toUiProfile( + accountKey: MicroBlogKey? = null, + imageHeaders: ImmutableMap? = null, +): UiProfile { + val userEntity = user + val key = fanboxCreatorKey(creatorId) + return UiProfile( + key = key, + handle = UiHandle(raw = creatorId, host = FANBOX_HOST), + avatar = userEntity?.iconUrl.toUiImage(imageHeaders), + nameInternal = (userEntity?.name ?: creatorId).toUiPlainText(), + platformType = PlatformType.Fanbox, + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.Profile.User( + accountType = accountKey?.let { AccountType.Specific(it) } ?: AccountType.GuestHost(FANBOX_HOST), + userKey = key, + ), + ), + banner = coverImageUrl.toUiImage(imageHeaders), + description = description.takeIf { it.isNotBlank() }?.toUiPlainText(), + matrices = + UiProfile.Matrices( + fansCount = 0, + followsCount = 0, + statusesCount = 0, + ), + mark = + if (isSupported) { + persistentListOf(UiProfile.Mark.Verified) + } else { + persistentListOf() + }, + bottomContent = + profileLinks + .takeIf { it.isNotEmpty() } + ?.mapIndexed { index, link -> "Link ${index + 1}" to link.toUiPlainText() } + ?.toMap() + ?.toPersistentMap() + ?.let { UiProfile.BottomContent.Fields(it) }, + ) +} + +internal fun FanboxCreatorDetailBody.toUiTimeline( + accountKey: MicroBlogKey, + imageHeaders: ImmutableMap? = null, +): UiTimelineV2.User { + val profile = toUiProfile(accountKey, imageHeaders) + return UiTimelineV2.User( + value = profile, + createdAt = Clock.System.now().toUi(), + statusKey = profile.key, + accountType = AccountType.Specific(accountKey), + ) +} + +internal fun FanboxCredential.toUiProfile( + accountKey: MicroBlogKey? = null, + profileKey: MicroBlogKey? = null, + imageHeaders: ImmutableMap? = toFanboxImageHeaders(), +): UiProfile { + val profileId = creatorId ?: userId + val key = profileKey ?: fanboxCreatorKey(profileId) + return UiProfile( + key = key, + handle = UiHandle(raw = profileId, host = FANBOX_HOST), + avatar = iconUrl.toUiImage(imageHeaders), + nameInternal = (name ?: profileId).toUiPlainText(), + platformType = PlatformType.Fanbox, + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.Profile.User( + accountType = accountKey?.let { AccountType.Specific(it) } ?: AccountType.GuestHost(FANBOX_HOST), + userKey = key, + ), + ), + banner = null, + description = null, + matrices = + UiProfile.Matrices( + fansCount = 0, + followsCount = 0, + statusesCount = 0, + ), + mark = + if (isSupporter || isCreator) { + persistentListOf(UiProfile.Mark.Verified) + } else { + persistentListOf() + }, + bottomContent = null, + ) +} + +private fun FanboxUserEntity.toUiProfile( + accountKey: MicroBlogKey, + creatorId: String?, + imageHeaders: ImmutableMap? = null, +): UiProfile { + val profileId = creatorId ?: userId + val key = fanboxCreatorKey(profileId) + return UiProfile( + key = key, + handle = UiHandle(raw = profileId, host = FANBOX_HOST), + avatar = iconUrl.toUiImage(imageHeaders), + nameInternal = (name.ifBlank { profileId }).toUiPlainText(), + platformType = PlatformType.Fanbox, + clickEvent = ClickEvent.Deeplink(profileRoute(accountKey, creatorId)), + banner = null, + description = null, + matrices = + UiProfile.Matrices( + fansCount = 0, + followsCount = 0, + statusesCount = 0, + ), + mark = persistentListOf(), + bottomContent = null, + ) +} + +private fun FanboxUserEntity.profileRoute( + accountKey: MicroBlogKey, + creatorId: String?, +): DeeplinkRoute = + DeeplinkRoute.Profile.User( + accountType = AccountType.Specific(accountKey), + userKey = fanboxCreatorKey(creatorId ?: userId), + ) + +private fun FanboxPostDetailBody.BodyContent.toArticleBlocks( + postId: String, + sensitive: Boolean, + imageHeaders: ImmutableMap? = null, +): List { + if (blocks.isNotEmpty()) { + return blocks.flatMapIndexed { index, block -> + block.toArticleBlocks( + keyPrefix = "$postId:block:$index", + content = this, + sensitive = sensitive, + imageHeaders = imageHeaders, + ) + } + } + + val fallbackBlocks = mutableListOf() + text + ?.takeIf { it.isNotBlank() } + ?.toArticleTextBlock(key = "$postId:text") + ?.let(fallbackBlocks::add) + images.mapIndexedTo(fallbackBlocks) { index, image -> + image.toArticleImageBlock( + key = "$postId:image:${image.id.ifBlank { index.toString() }}", + sensitive = sensitive, + imageHeaders = imageHeaders, + ) + } + files.forEachIndexed { index, file -> + fallbackBlocks.addAll( + file.toArticleFileBlocks( + key = "$postId:file:${file.id.ifBlank { index.toString() }}", + imageHeaders = imageHeaders, + ), + ) + } + return fallbackBlocks +} + +private fun FanboxPostDetailBody.Block.toArticleBlocks( + keyPrefix: String, + content: FanboxPostDetailBody.BodyContent, + sensitive: Boolean, + imageHeaders: ImmutableMap? = null, +): List { + text?.takeIf { it.isNotBlank() }?.let { + return listOf( + it.toArticleTextBlock( + key = keyPrefix, + headingLevel = type.toArticleHeadingLevel(), + ), + ) + } + imageId + ?.let(content.imageMap::get) + ?.let { + return listOf( + it.toArticleImageBlock( + key = "$keyPrefix:image:${it.id}", + sensitive = sensitive, + imageHeaders = imageHeaders, + ), + ) + } + fileId + ?.let(content.fileMap::get) + ?.let { + return it.toArticleFileBlocks( + key = "$keyPrefix:file:${it.id}", + imageHeaders = imageHeaders, + ) + } + urlEmbedId + ?.let(content.urlEmbedMap::get) + ?.let { + return listOf( + it.toArticleEmbedBlock( + key = "$keyPrefix:embed:${it.id}", + ), + ) + } + return emptyList() +} + +private fun String.toArticleTextBlock( + key: String, + headingLevel: Int? = null, +): UiArticleBlock.Text = + UiArticleBlock.Text( + key = key, + content = + RenderContent.Text( + runs = persistentListOf(RenderRun.Text(text = this)), + block = RenderBlockStyle(headingLevel = headingLevel), + ), + ) + +private fun String.toArticleHeadingLevel(): Int? = + when (this) { + "header", "h1" -> 1 + "h2" -> 2 + "h3" -> 3 + else -> null + } + +private fun FanboxPostDetailBody.ImageItem.toArticleImageBlock( + key: String, + sensitive: Boolean, + imageHeaders: ImmutableMap? = null, +): UiArticleBlock.Image = + UiArticleBlock.Image( + key = key, + media = + UiMedia.Image( + url = originalUrl, + previewUrl = thumbnailUrl.ifBlank { originalUrl }, + description = null, + height = height.toFloat(), + width = width.toFloat(), + sensitive = sensitive, + customHeaders = imageHeaders, + ), + ) + +private fun FanboxPostDetailBody.FileItem.toArticleFileBlocks( + key: String, + imageHeaders: ImmutableMap? = null, +): List = + buildList { + if (isArticleVideoFile()) { + add(toArticleVideoBlock(key = "$key:video", imageHeaders = imageHeaders)) + } + add(toArticleFileBlock(key = key)) + } + +private fun FanboxPostDetailBody.FileItem.toArticleVideoBlock( + key: String, + imageHeaders: ImmutableMap? = null, +): UiArticleBlock.Video = + UiArticleBlock.Video( + key = key, + media = + UiMedia.Video( + url = url, + thumbnailUrl = "", + description = name.takeIf { it.isNotBlank() }, + height = 9f, + width = 16f, + customHeaders = imageHeaders, + ), + ) + +private fun FanboxPostDetailBody.FileItem.toArticleFileBlock(key: String): UiArticleBlock.File = + UiArticleBlock.File( + key = key, + name = name, + url = url, + sizeBytes = size, + extension = extension.takeIf { it.isNotBlank() }, + ) + +private fun FanboxPostDetailBody.FileItem.isArticleVideoFile(): Boolean = articleFileExtension() in FANBOX_ARTICLE_VIDEO_EXTENSIONS + +private fun FanboxPostDetailBody.FileItem.articleFileExtension(): String = + sequenceOf( + extension, + name.substringAfterLast('.', missingDelimiterValue = ""), + url + .substringBefore("?") + .substringBefore("#") + .substringAfterLast('.', missingDelimiterValue = ""), + ).firstOrNull { it.isNotBlank() } + .orEmpty() + .lowercase() + +private fun FanboxPostDetailBody.UrlEmbed.toArticleEmbedBlock(key: String): UiArticleBlock.Embed = + UiArticleBlock.Embed( + key = key, + url = postInfo?.let { fanboxPostUrl(it.creatorId, it.id) }, + title = postInfo?.title, + description = postInfo?.excerpt?.takeIf { it.isNotBlank() }, + imageUrl = postInfo?.cover?.url, + htmlFallback = html, + ) + +private fun String?.toArticleCover( + sensitive: Boolean, + imageHeaders: ImmutableMap? = null, +): UiMedia.Image? = + this + ?.takeIf { it.isNotBlank() } + ?.let { + UiMedia.Image( + url = it, + previewUrl = it, + description = null, + height = 0f, + width = 0f, + sensitive = sensitive, + customHeaders = imageHeaders, + ) + } + +private fun FanboxPostDetailBody.toArticleCover(imageHeaders: ImmutableMap? = null): UiMedia.Image? = + (coverImageUrl ?: imageForShare ?: body?.firstImageUrl()).toArticleCover(hasAdultContent, imageHeaders) + +private fun FanboxPostDetailBody.BodyContent.firstImageUrl(): String? = + images.firstOrNull()?.let { image -> + image.thumbnailUrl.ifBlank { image.originalUrl } + } + ?: blocks.firstNotNullOfOrNull { block -> + block.imageId + ?.let(imageMap::get) + ?.let { image -> image.thumbnailUrl.ifBlank { image.originalUrl } } + } + +private fun fanboxPostUrl( + creatorId: String, + postId: String, +): String = "https://www.fanbox.cc/@$creatorId/posts/$postId" + +private fun parseFanboxInstant(value: String): Instant = + runCatching { + Instant.parse(value) + }.getOrElse { + Clock.System.now() + } diff --git a/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/datasource/fanbox/FanboxProfileLoaders.kt b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/datasource/fanbox/FanboxProfileLoaders.kt new file mode 100644 index 000000000..56b117fc5 --- /dev/null +++ b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/datasource/fanbox/FanboxProfileLoaders.kt @@ -0,0 +1,238 @@ +package dev.dimension.flare.data.datasource.fanbox + +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.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.fanbox.FanboxFollowRequest +import dev.dimension.flare.data.network.fanbox.FanboxService +import dev.dimension.flare.data.network.fanbox.requireCsrfToken +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 +import kotlinx.coroutines.CancellationException + +internal class FanboxLoader( + private val accountKey: MicroBlogKey, + private val service: FanboxService, +) : UserLoader, + RelationLoader, + PostLoader { + override val supportedTypes: Set = setOf(RelationActionType.Follow) + + override suspend fun userByHandleAndHost(uiHandle: UiHandle): UiProfile { + val creatorId = uiHandle.normalizedRaw.removePrefix("@") + val imageHeaders = service.fanboxImageHeaders() + return runCatching { + service + .getCreator(creatorId = creatorId) + .body + .toUiProfile( + accountKey = accountKey, + imageHeaders = imageHeaders, + ) + }.getOrElse { cause -> + if (cause is CancellationException) { + throw cause + } + service + .searchCreatorsRaw(query = creatorId, page = 0) + .body + .creators + .firstOrNull { it.creatorId == creatorId || it.user?.name == creatorId } + ?.toUiProfile( + accountKey = accountKey, + imageHeaders = imageHeaders, + ) + ?: throw NoSuchElementException("FANBOX creator not found: ${uiHandle.canonical}") + } + } + + override suspend fun userById(id: String): UiProfile { + if (id == accountKey.id) { + val credential = service.credentialWithCsrf() + return credential + .toUiProfile( + accountKey = accountKey, + profileKey = accountKey, + imageHeaders = credential.toFanboxImageHeaders(), + ) + } + return service + .getCreator(creatorId = id) + .body + .toUiProfile( + accountKey = accountKey, + imageHeaders = service.fanboxImageHeaders(), + ) + } + + override suspend fun relation(userKey: MicroBlogKey): UiRelation { + if (userKey == accountKey) { + return UiRelation() + } + val creator = service.getCreator(creatorId = userKey.id).body + return UiRelation( + following = creator.isFollowed, + isFans = creator.isSupported, + ) + } + + override suspend fun follow(userKey: MicroBlogKey) { + val creator = service.getCreator(creatorId = userKey.id).body + val userId = creator.user?.userId + require(!userId.isNullOrBlank()) { "FANBOX creator user id is missing: ${userKey.id}" } + val credential = service.credentialWithCsrf() + service.followCreator( + csrfToken = credential.requireCsrfToken(), + request = FanboxFollowRequest(userId), + ) + } + + override suspend fun unfollow(userKey: MicroBlogKey) { + val creator = service.getCreator(creatorId = userKey.id).body + val userId = creator.user?.userId + require(!userId.isNullOrBlank()) { "FANBOX creator user id is missing: ${userKey.id}" } + val credential = service.credentialWithCsrf() + service.unfollowCreator( + csrfToken = credential.requireCsrfToken(), + request = FanboxFollowRequest(userId), + ) + } + + override suspend fun block(userKey: MicroBlogKey): Unit = throw UnsupportedOperationException("FANBOX block is not supported") + + override suspend fun unblock(userKey: MicroBlogKey): Unit = throw UnsupportedOperationException("FANBOX block is not supported") + + override suspend fun mute(userKey: MicroBlogKey): Unit = throw UnsupportedOperationException("FANBOX mute is not supported") + + override suspend fun unmute(userKey: MicroBlogKey): Unit = throw UnsupportedOperationException("FANBOX mute is not supported") + + override suspend fun status(statusKey: MicroBlogKey): UiTimelineV2 = + service + .postInfo(postId = statusKey.id) + .body + .toUiTimeline( + accountKey = accountKey, + imageHeaders = service.fanboxImageHeaders(), + ) + + override suspend fun deleteStatus(statusKey: MicroBlogKey): Unit = + throw UnsupportedOperationException("FANBOX post deletion is not supported") +} + +internal class FanboxRecommendedCreatorLoader( + private val service: FanboxService, + private val accountKey: MicroBlogKey, +) : CacheableRemoteLoader { + override val pagingKey: String = "fanbox_recommended_creator_$accountKey" + + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request != PagingRequest.Refresh) { + return PagingResult(endOfPaginationReached = true) + } + val imageHeaders = service.fanboxImageHeaders() + return PagingResult( + data = + service + .listRecommendedCreators(limit = pageSize.coerceAtLeast(1)) + .body + .creators + .map { + it.toUiProfile( + accountKey = accountKey, + imageHeaders = imageHeaders, + ) + }, + endOfPaginationReached = true, + ) + } +} + +internal class FanboxFollowingCreatorLoader( + private val service: FanboxService, + private val accountKey: MicroBlogKey, +) : CacheableRemoteLoader { + override val pagingKey: String = "fanbox_following_creator_$accountKey" + + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request != PagingRequest.Refresh) { + return PagingResult(endOfPaginationReached = true) + } + val imageHeaders = service.fanboxImageHeaders() + return PagingResult( + data = + service + .listFollowingCreators() + .body + .creators + .map { + it.toUiProfile( + accountKey = accountKey, + imageHeaders = imageHeaders, + ) + }, + endOfPaginationReached = true, + ) + } +} + +internal class FanboxSearchCreatorLoader( + private val service: FanboxService, + private val accountKey: MicroBlogKey, + private val query: String, +) : CacheableRemoteLoader { + override val pagingKey: String = "fanbox_search_creator_${query}_$accountKey" + + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend) { + return PagingResult(endOfPaginationReached = true) + } + val response = + when (request) { + PagingRequest.Refresh -> { + service.searchCreatorsRaw(query = query, page = 0) + } + + is PagingRequest.Append -> { + service.searchCreatorsRaw( + query = query, + page = request.nextKey.toIntOrNull() ?: 0, + ) + } + + is PagingRequest.Prepend -> { + error("Handled above") + } + } + val imageHeaders = service.fanboxImageHeaders() + return PagingResult( + data = + response + .body + .creators + .map { + it.toUiProfile( + accountKey = accountKey, + imageHeaders = imageHeaders, + ) + }, + nextKey = response.body.nextPage?.toString(), + endOfPaginationReached = response.body.nextPage == null, + ) + } +} diff --git a/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/datasource/fanbox/FanboxTimelineLoaders.kt b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/datasource/fanbox/FanboxTimelineLoaders.kt new file mode 100644 index 000000000..69d62de10 --- /dev/null +++ b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/datasource/fanbox/FanboxTimelineLoaders.kt @@ -0,0 +1,302 @@ +package dev.dimension.flare.data.datasource.fanbox + +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.fanbox.FanboxCommentItem +import dev.dimension.flare.data.network.fanbox.FanboxPostPage +import dev.dimension.flare.data.network.fanbox.FanboxService +import dev.dimension.flare.data.network.fanbox.toFanboxCursor +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import io.ktor.http.Url + +internal abstract class FanboxTimelineLoader( + protected val service: FanboxService, + protected val accountKey: MicroBlogKey, +) : CacheableRemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend) { + return PagingResult(endOfPaginationReached = true) + } + + val response = + when (request) { + PagingRequest.Refresh -> loadPage(pageSize, null) + is PagingRequest.Append -> loadPage(pageSize, request.nextKey) + is PagingRequest.Prepend -> error("Handled above") + } + + val imageHeaders = service.fanboxImageHeaders() + return PagingResult( + data = + response + .items + .map { + it.toUiTimeline( + accountKey = accountKey, + imageHeaders = imageHeaders, + ) + }, + nextKey = response.nextKey, + endOfPaginationReached = response.nextKey == null, + ) + } + + protected abstract suspend fun loadPage( + pageSize: Int, + nextKey: String?, + ): FanboxPostPage +} + +internal class FanboxHomeTimelineLoader( + service: FanboxService, + accountKey: MicroBlogKey, +) : FanboxTimelineLoader(service, accountKey) { + override val pagingKey: String = "fanbox_home_$accountKey" + + override suspend fun loadPage( + pageSize: Int, + nextKey: String?, + ): FanboxPostPage { + val cursor = nextKey?.toFanboxCursor() + val response = + service.listHomePosts( + limit = (cursor?.limit ?: pageSize).coerceAtLeast(1), + firstPublishedDatetime = cursor?.firstPublishedDatetime, + maxPublishedDatetime = cursor?.maxPublishedDatetime, + firstId = cursor?.firstId, + maxId = cursor?.maxId, + ) + return FanboxPostPage( + items = response.body.items, + nextKey = response.body.nextUrl, + ) + } +} + +internal class FanboxSupportedTimelineLoader( + service: FanboxService, + accountKey: MicroBlogKey, +) : FanboxTimelineLoader(service, accountKey) { + override val pagingKey: String = "fanbox_supported_$accountKey" + + override suspend fun loadPage( + pageSize: Int, + nextKey: String?, + ): FanboxPostPage { + val cursor = nextKey?.toFanboxCursor() + val response = + service.listSupportingPosts( + limit = (cursor?.limit ?: pageSize).coerceAtLeast(1), + firstPublishedDatetime = cursor?.firstPublishedDatetime, + maxPublishedDatetime = cursor?.maxPublishedDatetime, + firstId = cursor?.firstId, + maxId = cursor?.maxId, + ) + return FanboxPostPage( + items = response.body.items, + nextKey = response.body.nextUrl, + ) + } +} + +internal class FanboxCreatorTimelineLoader( + service: FanboxService, + accountKey: MicroBlogKey, + private val creatorKey: MicroBlogKey, +) : FanboxTimelineLoader(service, accountKey) { + override val pagingKey: String = "fanbox_creator_${creatorKey.id}_$accountKey" + + override suspend fun loadPage( + pageSize: Int, + nextKey: String?, + ): FanboxPostPage { + val creatorId = creatorKey.resolveCreatorId() ?: return FanboxPostPage(emptyList(), null) + val pages = service.paginateCreatorPosts(creatorId = creatorId).body + val currentPage = nextKey ?: pages.firstOrNull() + val nextPage = + currentPage + ?.let { page -> pages.indexOf(page).takeIf { it >= 0 } } + ?.let { index -> pages.getOrNull(index + 1) } + + if (currentPage == null) { + return FanboxPostPage(emptyList(), null) + } + + val cursor = currentPage.toFanboxCursor() + val response = + service.listCreatorPosts( + creatorId = creatorId, + limit = (cursor.limit ?: pageSize).coerceAtLeast(1), + firstPublishedDatetime = cursor.firstPublishedDatetime, + maxPublishedDatetime = cursor.maxPublishedDatetime, + firstId = cursor.firstId, + maxId = cursor.maxId, + ) + return FanboxPostPage( + items = response.body, + nextKey = nextPage, + ) + } + + private suspend fun MicroBlogKey.resolveCreatorId(): String? = + if (this == accountKey) { + service + .credentialWithCsrf() + .creatorId + ?.takeIf { it.isNotBlank() } + } else { + id + } +} + +internal class FanboxSearchTimelineLoader( + service: FanboxService, + accountKey: MicroBlogKey, + private val query: String, +) : FanboxTimelineLoader(service, accountKey) { + override val pagingKey: String = "fanbox_search_${query}_$accountKey" + + override suspend fun loadPage( + pageSize: Int, + nextKey: String?, + ): FanboxPostPage { + val page = nextKey?.toIntOrNull() ?: 0 + val response = + service.listTaggedPosts( + tag = query.trimStart('#'), + page = page, + ) + return FanboxPostPage( + items = response.body.items, + nextKey = + response.body.nextUrl?.let { url -> + Url(url).parameters["page"] + }, + ) + } +} + +internal class FanboxStatusDetailLoader( + private val service: FanboxService, + private val accountKey: MicroBlogKey, + private val statusKey: MicroBlogKey, +) : CacheableRemoteLoader { + override val pagingKey: String = "fanbox_status_${statusKey}_$accountKey" + + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request != PagingRequest.Refresh) { + return PagingResult(endOfPaginationReached = true) + } + val imageHeaders = service.fanboxImageHeaders() + return PagingResult( + data = + listOf( + service + .postInfo(postId = statusKey.id) + .body + .toUiTimeline( + accountKey = accountKey, + imageHeaders = imageHeaders, + ), + ), + endOfPaginationReached = true, + ) + } +} + +internal class FanboxCommentsLoader( + private val service: FanboxService, + private val accountKey: MicroBlogKey, + private val statusKey: MicroBlogKey, +) : CacheableRemoteLoader { + override val pagingKey: String = "fanbox_comments_${statusKey}_$accountKey" + + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend) { + return PagingResult(endOfPaginationReached = true) + } + + val (comments, nextKey) = + when (request) { + PagingRequest.Refresh -> loadCommentPage(nextKey = null) + is PagingRequest.Append -> loadCommentPage(nextKey = request.nextKey) + is PagingRequest.Prepend -> error("Handled above") + } + + val imageHeaders = service.fanboxImageHeaders() + return PagingResult( + data = + comments.map { + it.toUiTimeline( + accountKey = accountKey, + postKey = statusKey, + imageHeaders = imageHeaders, + ) + }, + nextKey = nextKey, + endOfPaginationReached = nextKey == null, + ) + } + + private suspend fun loadCommentPage(nextKey: String?): Pair, String?> { + val offset = nextKey?.toIntOrNull() ?: 0 + val response = + service.getComments( + postId = statusKey.id, + offset = offset, + limit = 20, + ) + val nextOffset = + response.body.commentList.nextUrl?.let { url -> + Url(url).parameters["offset"] + } + val comments = + response.body.commentList.items + .flatMap { it.flatten() } + return comments to nextOffset + } +} + +internal class FanboxRecommendedCreatorsTimelineLoader( + private val service: FanboxService, + private val accountKey: MicroBlogKey, +) : CacheableRemoteLoader { + override val pagingKey: String = "fanbox_recommended_creators_$accountKey" + + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request != PagingRequest.Refresh) { + return PagingResult(endOfPaginationReached = true) + } + val imageHeaders = service.fanboxImageHeaders() + return PagingResult( + data = + service + .listRecommendedCreators(limit = pageSize.coerceAtLeast(1)) + .body + .creators + .map { + it.toUiTimeline( + accountKey = accountKey, + imageHeaders = imageHeaders, + ) + }, + endOfPaginationReached = true, + ) + } +} + +private fun FanboxCommentItem.flatten(): List = listOf(this) + replies.flatMap { it.flatten() } diff --git a/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/FanboxModels.kt b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/FanboxModels.kt new file mode 100644 index 000000000..1d543d3a6 --- /dev/null +++ b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/FanboxModels.kt @@ -0,0 +1,400 @@ +package dev.dimension.flare.data.network.fanbox + +import io.ktor.http.Url +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class FanboxMetaDataEntity( + @SerialName("apiUrl") + val apiUrl: String? = null, + @SerialName("context") + val context: Context? = null, + @SerialName("csrfToken") + val csrfToken: String, +) { + @Serializable + internal data class Context( + @SerialName("user") + val user: User? = null, + ) { + @Serializable + internal data class User( + @SerialName("creatorId") + val creatorId: String? = null, + @SerialName("iconUrl") + val iconUrl: String? = null, + @SerialName("isCreator") + val isCreator: Boolean = false, + @SerialName("isSupporter") + val isSupporter: Boolean = false, + @SerialName("name") + val name: String = "", + @SerialName("showAdultContent") + val showAdultContent: Boolean = false, + @SerialName("userId") + val userId: String? = null, + ) + } +} + +@Serializable +internal data class FanboxPostIdRequest( + @SerialName("postId") + val postId: String, +) + +@Serializable +internal data class FanboxFollowRequest( + @SerialName("creatorUserId") + val creatorUserId: String, +) + +@Serializable +internal data class FanboxUserEntity( + @SerialName("iconUrl") + val iconUrl: String? = null, + @SerialName("name") + val name: String = "", + @SerialName("userId") + val userId: String = "", +) + +@Serializable +internal data class FanboxCoverEntity( + @SerialName("type") + val type: String = "", + @SerialName("url") + val url: String = "", +) + +@Serializable +internal data class FanboxPostEntity( + @SerialName("commentCount") + val commentCount: Int = 0, + @SerialName("cover") + val cover: FanboxCoverEntity? = null, + @SerialName("creatorId") + val creatorId: String = "", + @SerialName("excerpt") + val excerpt: String = "", + @SerialName("feeRequired") + val feeRequired: Int = 0, + @SerialName("hasAdultContent") + val hasAdultContent: Boolean = false, + @SerialName("id") + val id: String = "", + @SerialName("isLiked") + val isLiked: Boolean = false, + @SerialName("isRestricted") + val isRestricted: Boolean = false, + @SerialName("likeCount") + val likeCount: Int = 0, + @SerialName("publishedDatetime") + val publishedDatetime: String = "", + @SerialName("tags") + val tags: List = emptyList(), + @SerialName("title") + val title: String = "", + @SerialName("updatedDatetime") + val updatedDatetime: String = "", + @SerialName("user") + val user: FanboxUserEntity? = null, +) + +@Serializable +internal data class FanboxPostListResponse( + @SerialName("body") + val body: Body = Body(), +) { + @Serializable + internal data class Body( + @SerialName("items") + val items: List = emptyList(), + @SerialName("nextUrl") + val nextUrl: String? = null, + ) +} + +@Serializable +internal data class FanboxCreatorPostListResponse( + @SerialName("body") + val body: List = emptyList(), +) + +@Serializable +internal data class FanboxCreatorPostPagesResponse( + @SerialName("body") + val body: List = emptyList(), +) + +@Serializable +internal data class FanboxPostSearchResponse( + @SerialName("body") + val body: Body = Body(), +) { + @Serializable + internal data class Body( + @SerialName("items") + val items: List = emptyList(), + @SerialName("nextUrl") + val nextUrl: String? = null, + ) +} + +@Serializable +internal data class FanboxPostDetailResponse( + @SerialName("body") + val body: FanboxPostDetailBody = FanboxPostDetailBody(), +) + +@Serializable +internal data class FanboxPostDetailBody( + @SerialName("body") + val body: BodyContent? = null, + @SerialName("commentCount") + val commentCount: Int = 0, + @SerialName("creatorId") + val creatorId: String = "", + @SerialName("excerpt") + val excerpt: String = "", + @SerialName("feeRequired") + val feeRequired: Int = 0, + @SerialName("hasAdultContent") + val hasAdultContent: Boolean = false, + @SerialName("id") + val id: String = "", + @SerialName("imageForShare") + val imageForShare: String? = null, + @SerialName("isLiked") + val isLiked: Boolean = false, + @SerialName("isRestricted") + val isRestricted: Boolean = false, + @SerialName("likeCount") + val likeCount: Int = 0, + @SerialName("publishedDatetime") + val publishedDatetime: String = "", + @SerialName("tags") + val tags: List = emptyList(), + @SerialName("title") + val title: String = "", + @SerialName("type") + val type: String = "", + @SerialName("updatedDatetime") + val updatedDatetime: String = "", + @SerialName("coverImageUrl") + val coverImageUrl: String? = null, + @SerialName("user") + val user: FanboxUserEntity? = null, +) { + @Serializable + internal data class BodyContent( + @SerialName("text") + val text: String? = null, + @SerialName("blocks") + val blocks: List = emptyList(), + @SerialName("fileMap") + val fileMap: Map = emptyMap(), + @SerialName("imageMap") + val imageMap: Map = emptyMap(), + @SerialName("urlEmbedMap") + val urlEmbedMap: Map = emptyMap(), + @SerialName("images") + val images: List = emptyList(), + @SerialName("files") + val files: List = emptyList(), + ) + + @Serializable + internal data class Block( + @SerialName("type") + val type: String = "", + @SerialName("text") + val text: String? = null, + @SerialName("imageId") + val imageId: String? = null, + @SerialName("fileId") + val fileId: String? = null, + @SerialName("urlEmbedId") + val urlEmbedId: String? = null, + ) + + @Serializable + internal data class FileItem( + @SerialName("extension") + val extension: String = "", + @SerialName("id") + val id: String = "", + @SerialName("name") + val name: String = "", + @SerialName("size") + val size: Long = 0, + @SerialName("url") + val url: String = "", + ) + + @Serializable + internal data class ImageItem( + @SerialName("extension") + val extension: String = "", + @SerialName("height") + val height: Int = 0, + @SerialName("id") + val id: String = "", + @SerialName("originalUrl") + val originalUrl: String = "", + @SerialName("thumbnailUrl") + val thumbnailUrl: String = "", + @SerialName("width") + val width: Int = 0, + ) + + @Serializable + internal data class UrlEmbed( + @SerialName("id") + val id: String = "", + @SerialName("type") + val type: String = "", + @SerialName("html") + val html: String? = null, + @SerialName("postInfo") + val postInfo: FanboxPostEntity? = null, + ) +} + +@Serializable +internal data class FanboxCreatorDetailResponse( + @SerialName("body") + val body: FanboxCreatorDetailBody = FanboxCreatorDetailBody(), +) + +@Serializable +internal data class FanboxCreatorDetailBody( + @SerialName("coverImageUrl") + val coverImageUrl: String? = null, + @SerialName("creatorId") + val creatorId: String = "", + @SerialName("description") + val description: String = "", + @SerialName("hasAdultContent") + val hasAdultContent: Boolean = false, + @SerialName("isFollowed") + val isFollowed: Boolean = false, + @SerialName("isSupported") + val isSupported: Boolean = false, + @SerialName("profileItems") + val profileItems: List = emptyList(), + @SerialName("profileLinks") + val profileLinks: List = emptyList(), + @SerialName("user") + val user: FanboxUserEntity? = null, +) { + @Serializable + internal data class ProfileItem( + @SerialName("id") + val id: String = "", + @SerialName("imageUrl") + val imageUrl: String? = null, + @SerialName("thumbnailUrl") + val thumbnailUrl: String? = null, + @SerialName("type") + val type: String = "", + ) +} + +@Serializable +internal data class FanboxCreatorListResponse( + @SerialName("body") + val body: Body = Body(), +) { + @Serializable + internal data class Body( + @SerialName("creators") + val creators: List = emptyList(), + ) +} + +@Serializable +internal data class FanboxCreatorSearchResponse( + @SerialName("body") + val body: Body = Body(), +) { + @Serializable + internal data class Body( + @SerialName("creators") + val creators: List = emptyList(), + @SerialName("nextPage") + val nextPage: Int? = null, + ) +} + +@Serializable +internal data class FanboxCommentListResponse( + @SerialName("body") + val body: Body = Body(), +) { + @Serializable + internal data class Body( + @SerialName("commentList") + val commentList: CommentList = CommentList(), + ) + + @Serializable + internal data class CommentList( + @SerialName("items") + val items: List = emptyList(), + @SerialName("nextUrl") + val nextUrl: String? = null, + ) +} + +@Serializable +internal data class FanboxCommentItem( + @SerialName("body") + val body: String = "", + @SerialName("createdDatetime") + val createdDatetime: String = "", + @SerialName("id") + val id: String = "", + @SerialName("isLiked") + val isLiked: Boolean = false, + @SerialName("likeCount") + val likeCount: Int = 0, + @SerialName("parentCommentId") + val parentCommentId: String = "", + @SerialName("rootCommentId") + val rootCommentId: String = "", + @SerialName("user") + val user: FanboxUserEntity? = null, + @SerialName("replies") + val replies: List = emptyList(), +) + +internal data class FanboxPostPage( + val items: List, + val nextKey: String?, +) + +internal data class FanboxCreatorPage( + val items: List, + val nextKey: String?, +) + +internal data class FanboxCursor( + val firstPublishedDatetime: String?, + val maxPublishedDatetime: String?, + val firstId: String?, + val maxId: String?, + val limit: Int?, +) + +internal fun String.toFanboxCursor(): FanboxCursor { + val parameters = Url(this).parameters + return FanboxCursor( + firstPublishedDatetime = parameters["firstPublishedDatetime"], + maxPublishedDatetime = parameters["maxPublishedDatetime"], + firstId = parameters["firstId"], + maxId = parameters["maxId"], + limit = parameters["limit"]?.toIntOrNull(), + ) +} diff --git a/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/FanboxPlatformDetector.kt b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/FanboxPlatformDetector.kt new file mode 100644 index 000000000..9a1556c28 --- /dev/null +++ b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/FanboxPlatformDetector.kt @@ -0,0 +1,23 @@ +package dev.dimension.flare.data.network.fanbox + +import dev.dimension.flare.data.network.nodeinfo.NodeData +import dev.dimension.flare.data.network.nodeinfo.PlatformDetector +import dev.dimension.flare.data.platform.FANBOX_HOST +import dev.dimension.flare.data.platform.FANBOX_WEB_HOST +import dev.dimension.flare.model.PlatformType + +internal data object FanboxPlatformDetector : PlatformDetector { + override val priority: Int = 80 + + override suspend fun detect(host: String): NodeData? { + if (!FANBOX_HOST.equals(host, ignoreCase = true) && !FANBOX_WEB_HOST.equals(host, ignoreCase = true)) { + return null + } + return NodeData( + host = FANBOX_HOST, + platformType = PlatformType.Fanbox, + software = PlatformType.Fanbox.name, + compatibleMode = false, + ) + } +} diff --git a/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/FanboxService.kt b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/FanboxService.kt new file mode 100644 index 000000000..c84270e02 --- /dev/null +++ b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/FanboxService.kt @@ -0,0 +1,135 @@ +package dev.dimension.flare.data.network.fanbox + +import com.fleeksoft.ksoup.Ksoup +import dev.dimension.flare.common.JSON +import dev.dimension.flare.data.network.fanbox.api.FanboxResources +import dev.dimension.flare.data.network.fanbox.api.FanboxWebResources +import dev.dimension.flare.data.network.fanbox.api.createFanboxResources +import dev.dimension.flare.data.network.fanbox.api.createFanboxWebResources +import dev.dimension.flare.data.network.ktorfit +import dev.dimension.flare.data.platform.FANBOX_WEB_HOST +import dev.dimension.flare.data.platform.FanboxCredential +import io.ktor.client.plugins.api.createClientPlugin +import io.ktor.http.HttpHeaders +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flowOf + +private const val FANBOX_API_URL = "https://api.fanbox.cc/" +internal const val FANBOX_WEB_URL = "https://www.fanbox.cc/" +internal const val FANBOX_USER_AGENT = + "Mozilla/5.0 (Linux; Android 13; Flare) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Mobile Safari/537.36" +private const val FANBOX_JSON_ACCEPT = "application/json, text/plain, */*" +private const val FANBOX_HTML_ACCEPT = "text/html,*/*" + +private class FanboxHeaderConfig { + var accept: String = FANBOX_JSON_ACCEPT + var credentialFlow: Flow? = null +} + +private val FanboxHeaderPlugin = + createClientPlugin("FanboxHeaderPlugin", ::FanboxHeaderConfig) { + val accept = pluginConfig.accept + val credentialFlow = pluginConfig.credentialFlow + onRequest { request, _ -> + credentialFlow + ?.firstOrNull() + ?.sessionId + ?.takeIf { it.isNotBlank() } + ?.let { sessionId -> + request.headers.append(HttpHeaders.Cookie, fanboxSessionCookie(sessionId)) + } + request.headers.append(HttpHeaders.UserAgent, FANBOX_USER_AGENT) + request.headers.append("Origin", "https://$FANBOX_WEB_HOST") + request.headers.append("Referer", "https://$FANBOX_WEB_HOST/") + request.headers.append("Accept", accept) + } + } + +private fun fanboxKtorfit( + baseUrl: String, + accept: String, + credentialFlow: Flow, +) = ktorfit(baseUrl) { + expectSuccess = true + install(FanboxHeaderPlugin) { + this.accept = accept + this.credentialFlow = credentialFlow + } +} + +private fun fanboxResources(credentialFlow: Flow): FanboxResources = + fanboxKtorfit(FANBOX_API_URL, FANBOX_JSON_ACCEPT, credentialFlow) + .createFanboxResources() + +private fun fanboxWebResources(credentialFlow: Flow): FanboxWebResources = + fanboxKtorfit(FANBOX_WEB_URL, FANBOX_HTML_ACCEPT, credentialFlow) + .createFanboxWebResources() + +internal class FanboxService( + private val credentialFlow: Flow, + private val onCredentialRefreshed: suspend (FanboxCredential) -> Unit = {}, +) : FanboxResources by fanboxResources(credentialFlow) { + private val webResources: FanboxWebResources = + fanboxWebResources(credentialFlow) + + suspend fun metadata(): FanboxMetaDataEntity = webResources.metadata().toFanboxMetadata() + + suspend fun metadata(sessionId: String): FanboxMetaDataEntity { + val html = + fanboxWebResources( + flowOf(FanboxCredential(sessionId = sessionId, userId = "")), + ).metadata() + return html.toFanboxMetadata() + } + + suspend fun credentialWithCsrf(): FanboxCredential = credentialWithCsrf(forceRefresh = false) + + suspend fun refreshCredential(): FanboxCredential = credentialWithCsrf(forceRefresh = true) + + suspend fun currentCredential(): FanboxCredential = credential() + + private suspend fun credential(): FanboxCredential = credentialFlow.first() + + private suspend fun credentialWithCsrf(forceRefresh: Boolean = false): FanboxCredential { + val current = credential() + if (!forceRefresh && !current.csrfToken.isNullOrBlank()) { + return current + } + val metadata = metadata() + val user = metadata.context?.user + val updated = + current.copy( + csrfToken = metadata.csrfToken, + userId = user?.userId ?: current.userId, + creatorId = user?.creatorId ?: current.creatorId, + name = user?.name ?: current.name, + iconUrl = user?.iconUrl ?: current.iconUrl, + showAdultContent = user?.showAdultContent ?: current.showAdultContent, + isSupporter = user?.isSupporter ?: current.isSupporter, + isCreator = user?.isCreator ?: current.isCreator, + ) + if (updated != current) { + onCredentialRefreshed(updated) + } + return updated + } +} + +private fun fanboxSessionCookie(sessionId: String): String = "FANBOXSESSID=$sessionId" + +internal fun FanboxCredential.requireCsrfToken(): String = + csrfToken?.takeIf { it.isNotBlank() } + ?: error("FANBOX CSRF token is missing") + +private fun String.toFanboxMetadata(): FanboxMetaDataEntity { + val content = + Ksoup + .parse(this) + .selectFirst("meta[name=metadata]") + ?.attr("content") + ?.takeIf { it.isNotBlank() } + ?: error("FANBOX metadata is missing") + return JSON.decodeFromString(FanboxMetaDataEntity.serializer(), content) +} diff --git a/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/api/FanboxResources.kt b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/api/FanboxResources.kt new file mode 100644 index 000000000..e4a9c4efc --- /dev/null +++ b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/api/FanboxResources.kt @@ -0,0 +1,113 @@ +package dev.dimension.flare.data.network.fanbox.api + +import de.jensklingenberg.ktorfit.http.Body +import de.jensklingenberg.ktorfit.http.GET +import de.jensklingenberg.ktorfit.http.Header +import de.jensklingenberg.ktorfit.http.POST +import de.jensklingenberg.ktorfit.http.Query +import dev.dimension.flare.data.network.fanbox.FanboxCommentListResponse +import dev.dimension.flare.data.network.fanbox.FanboxCreatorDetailResponse +import dev.dimension.flare.data.network.fanbox.FanboxCreatorListResponse +import dev.dimension.flare.data.network.fanbox.FanboxCreatorPostListResponse +import dev.dimension.flare.data.network.fanbox.FanboxCreatorPostPagesResponse +import dev.dimension.flare.data.network.fanbox.FanboxCreatorSearchResponse +import dev.dimension.flare.data.network.fanbox.FanboxFollowRequest +import dev.dimension.flare.data.network.fanbox.FanboxPostDetailResponse +import dev.dimension.flare.data.network.fanbox.FanboxPostIdRequest +import dev.dimension.flare.data.network.fanbox.FanboxPostListResponse +import dev.dimension.flare.data.network.fanbox.FanboxPostSearchResponse + +internal interface FanboxWebResources { + @GET("/") + suspend fun metadata(): String +} + +internal interface FanboxResources { + @GET("post.listHome") + suspend fun listHomePosts( + @Query("limit") limit: Int, + @Query("firstPublishedDatetime") firstPublishedDatetime: String? = null, + @Query("maxPublishedDatetime") maxPublishedDatetime: String? = null, + @Query("firstId") firstId: String? = null, + @Query("maxId") maxId: String? = null, + ): FanboxPostListResponse + + @GET("post.listSupporting") + suspend fun listSupportingPosts( + @Query("limit") limit: Int, + @Query("firstPublishedDatetime") firstPublishedDatetime: String? = null, + @Query("maxPublishedDatetime") maxPublishedDatetime: String? = null, + @Query("firstId") firstId: String? = null, + @Query("maxId") maxId: String? = null, + ): FanboxPostListResponse + + @GET("post.paginateCreator") + suspend fun paginateCreatorPosts( + @Query("creatorId") creatorId: String, + ): FanboxCreatorPostPagesResponse + + @GET("post.listCreator") + suspend fun listCreatorPosts( + @Query("creatorId") creatorId: String, + @Query("limit") limit: Int, + @Query("firstPublishedDatetime") firstPublishedDatetime: String? = null, + @Query("maxPublishedDatetime") maxPublishedDatetime: String? = null, + @Query("firstId") firstId: String? = null, + @Query("maxId") maxId: String? = null, + ): FanboxCreatorPostListResponse + + @GET("post.listTagged") + suspend fun listTaggedPosts( + @Query("tag") tag: String, + @Query("page") page: Int, + ): FanboxPostSearchResponse + + @GET("post.info") + suspend fun postInfo( + @Query("postId") postId: String, + ): FanboxPostDetailResponse + + @GET("post.getComments") + suspend fun getComments( + @Query("postId") postId: String, + @Query("offset") offset: Int, + @Query("limit") limit: Int, + ): FanboxCommentListResponse + + @GET("creator.get") + suspend fun getCreator( + @Query("creatorId") creatorId: String, + ): FanboxCreatorDetailResponse + + @GET("creator.listFollowing") + suspend fun listFollowingCreators(): FanboxCreatorListResponse + + @GET("creator.listRecommended") + suspend fun listRecommendedCreators( + @Query("limit") limit: Int, + ): FanboxCreatorListResponse + + @GET("creator.search") + suspend fun searchCreatorsRaw( + @Query("q") query: String, + @Query("page") page: Int, + ): FanboxCreatorSearchResponse + + @POST("post.likePost") + suspend fun likePost( + @Header("x-csrf-token") csrfToken: String, + @Body request: FanboxPostIdRequest, + ): Unit + + @POST("follow.create") + suspend fun followCreator( + @Header("x-csrf-token") csrfToken: String, + @Body request: FanboxFollowRequest, + ): Unit + + @POST("follow.delete") + suspend fun unfollowCreator( + @Header("x-csrf-token") csrfToken: String, + @Body request: FanboxFollowRequest, + ): Unit +} diff --git a/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/platform/FanboxCredential.kt b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/platform/FanboxCredential.kt new file mode 100644 index 000000000..f88ff06dc --- /dev/null +++ b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/platform/FanboxCredential.kt @@ -0,0 +1,19 @@ +package dev.dimension.flare.data.platform + +import kotlinx.serialization.Serializable + +@Serializable +public data class FanboxCredential( + val sessionId: String, + val csrfToken: String? = null, + val userId: String, + val creatorId: String? = null, + val name: String? = null, + val iconUrl: String? = null, + val showAdultContent: Boolean = false, + val isSupporter: Boolean = false, + val isCreator: Boolean = false, +) + +public const val FANBOX_HOST: String = "fanbox.cc" +public const val FANBOX_WEB_HOST: String = "www.fanbox.cc" diff --git a/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/platform/FanboxPlatformSpec.kt b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/platform/FanboxPlatformSpec.kt new file mode 100644 index 000000000..03901a221 --- /dev/null +++ b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/platform/FanboxPlatformSpec.kt @@ -0,0 +1,110 @@ +package dev.dimension.flare.data.platform + +import dev.dimension.flare.data.datasource.fanbox.FanboxDataSource +import dev.dimension.flare.data.datasource.fanbox.fanboxCreatorKey +import dev.dimension.flare.data.datasource.fanbox.fanboxPostKey +import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource +import dev.dimension.flare.data.model.tab.TimelineSpec +import dev.dimension.flare.data.model.tab.TimelineSpecIds +import dev.dimension.flare.data.model.tab.accountLoader +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformDataSourceContext +import dev.dimension.flare.model.PlatformDeepLink +import dev.dimension.flare.model.PlatformSpec +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.model.PlatformTypeMetadata +import dev.dimension.flare.ui.model.UiIcon +import dev.dimension.flare.ui.model.UiStrings +import dev.dimension.flare.ui.model.asType +import dev.dimension.flare.ui.presenter.login.FanboxLoginProvider +import dev.dimension.flare.ui.presenter.login.LoginPlatformProvider +import dev.dimension.flare.ui.route.DeeplinkRoute +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.serialization.Serializable +import kotlin.native.HiddenFromObjC + +@HiddenFromObjC +public data object FanboxPlatformSpec : + PlatformSpec, + LoginPlatformProvider by FanboxLoginProvider { + override val type: PlatformType = PlatformType.Fanbox + override val metadata: PlatformTypeMetadata = + PlatformTypeMetadata( + displayName = "FANBOX", + icon = UiIcon.Fanbox, + ) + + internal val supportedTimelineSpec = + TimelineSpec( + id = TimelineSpecIds.FANBOX_SUPPORTED, + title = UiStrings.FanboxSupported, + icon = UiIcon.Heart.asType(), + serializer = TimelineSpec.AccountBasedData.serializer(), + targetId = { it.accountKey.toString() }, + loaderFactory = + accountLoader { + supportedTimelineLoader() + }, + ) + + override val timelineSpecs: ImmutableList> = + persistentListOf( + CommonTimelineSpecs.home, + CommonTimelineSpecs.discover, + supportedTimelineSpec, + ) + + override fun deepLinks(accountKey: MicroBlogKey): ImmutableList> = + persistentListOf( + PlatformDeepLink( + uriPattern = "https://www.fanbox.cc/@{creatorId}/posts/{id}", + serializer = FanboxPostDeepLink.serializer(), + callback = { data -> + DeeplinkRoute.Article( + accountType = AccountType.Specific(accountKey), + articleKey = fanboxPostKey(data.id), + ) + }, + ), + PlatformDeepLink( + uriPattern = "https://www.fanbox.cc/@{creatorId}", + serializer = FanboxCreatorDeepLink.serializer(), + callback = { data -> + DeeplinkRoute.Profile.User( + accountType = AccountType.Specific(accountKey), + userKey = fanboxCreatorKey(data.creatorId), + ) + }, + ), + ) + + override fun createDataSource(context: PlatformDataSourceContext): MicroblogDataSource = + FanboxDataSource( + accountKey = context.accountKey, + credentialFlow = context.credentialFlow(FanboxCredential.serializer()), + updateCredential = { credential -> + context.updateCredential( + serializer = FanboxCredential.serializer(), + credential = credential, + ) + }, + ) + + override fun guestDataSource( + host: String, + locale: String, + ): MicroblogDataSource = throw UnsupportedOperationException("FANBOX guest data source is not supported") +} + +@Serializable +private data class FanboxPostDeepLink( + val creatorId: String, + val id: String, +) + +@Serializable +private data class FanboxCreatorDeepLink( + val creatorId: String, +) diff --git a/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/FanboxLoginProvider.kt b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/FanboxLoginProvider.kt new file mode 100644 index 000000000..90d589e76 --- /dev/null +++ b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/FanboxLoginProvider.kt @@ -0,0 +1,195 @@ +package dev.dimension.flare.ui.presenter.login + +import dev.dimension.flare.data.network.fanbox.FanboxPlatformDetector +import dev.dimension.flare.data.network.fanbox.FanboxService +import dev.dimension.flare.data.network.nodeinfo.PlatformDetector +import dev.dimension.flare.data.platform.FANBOX_HOST +import dev.dimension.flare.data.platform.FanboxCredential +import dev.dimension.flare.data.platform.FanboxPlatformSpec +import dev.dimension.flare.data.repository.AccountService +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.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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +private const val LOGIN_ACTION = "login" +private const val FANBOX_SESSION_COOKIE = "FANBOXSESSID" +private const val FANBOX_LOGIN_URL = "https://www.fanbox.cc/login" + +public data object FanboxLoginProvider : LoginPlatformProvider { + override val platformType: PlatformType = PlatformType.Fanbox + override val metadata: PlatformTypeMetadata + get() = FanboxPlatformSpec.metadata + override val detector: PlatformDetector = FanboxPlatformDetector + override val methods: List = + listOf( + LoginMethodSpec( + type = LoginMethodType.WebCookie, + title = UiStrings.WebCookieLogin, + ), + ) + + override fun agreementUrl(host: String): String? = "https://www.fanbox.cc/terms" + + override suspend fun recommendInstances(): List = + listOf( + RecommendedInstance( + instance = + UiInstance( + name = "FANBOX", + description = "Creator posts and supporter-only content.", + iconUrl = null, + domain = FANBOX_HOST, + type = platformType, + bannerUrl = null, + usersCount = 0, + ), + priority = 70, + ), + ) + + override suspend fun instanceMetadata(host: String): UiInstanceMetadata = + throw UnsupportedOperationException("${platformType.name} metadata is not supported yet") + + override fun createHandler(context: LoginContext): LoginMethodHandler { + require(context.methodType == LoginMethodType.WebCookie) { + "Unsupported FANBOX login method: ${context.methodType}" + } + return FanboxWebCookieLoginHandler(context) + } +} + +private class FanboxWebCookieLoginHandler( + private val context: LoginContext, +) : LoginMethodHandler, + KoinComponent { + private val accountService: AccountService by inject() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val _state = MutableStateFlow(state()) + private val _effects = MutableSharedFlow(extraBufferCapacity = 1) + private val validatedSessionId = MutableStateFlow(null) + private val validatingSessionId = MutableStateFlow(null) + + 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 + _effects.emit(LoginEffect.OpenWebCookieLogin(FANBOX_LOGIN_URL)) + } + + override suspend fun resume(value: String) { + _state.value = state(loading = true) + runCatching { + val sessionId = value.extractFanboxSession() + require(!sessionId.isNullOrBlank()) { "FANBOX session cookie is missing" } + val service = FanboxService(flowOf(FanboxCredential(sessionId = sessionId, userId = ""))) + val metadata = service.metadata(sessionId) + val user = metadata.context?.user + val userId = user?.userId + require(!userId.isNullOrBlank()) { "FANBOX user id is missing" } + val credential = + FanboxCredential( + sessionId = sessionId, + csrfToken = metadata.csrfToken, + userId = userId, + creatorId = user.creatorId, + name = user.name, + iconUrl = user.iconUrl, + showAdultContent = user.showAdultContent, + isSupporter = user.isSupporter, + isCreator = user.isCreator, + ) + accountService.addAccount( + account = + UiAccount( + accountKey = MicroBlogKey(id = userId, host = FANBOX_HOST), + platformType = PlatformType.Fanbox, + ), + credential = credential, + serializer = FanboxCredential.serializer(), + ) + context.onSuccess() + }.onFailure { + _state.value = state(error = it.message) + } + } + + override fun canResume(value: String): Boolean { + val sessionId = value.extractFanboxSession() ?: return false + if (validatedSessionId.value == sessionId) { + return true + } + if (validatingSessionId.value != sessionId) { + validatingSessionId.value = sessionId + scope.launch { + val isLoggedIn = + runCatching { + val service = FanboxService(flowOf(FanboxCredential(sessionId = sessionId, userId = ""))) + val user = service.metadata(sessionId).context?.user + !user?.userId.isNullOrBlank() + }.getOrDefault(false) + if (isLoggedIn) { + validatedSessionId.value = sessionId + } + if (validatingSessionId.value == sessionId) { + validatingSessionId.value = null + } + } + } + return false + } + + override fun clear() { + _state.value = state() + } + + override fun close() { + scope.cancel() + } + + private fun state( + 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 String.extractFanboxSession(): String? = + split(";") + .map { it.trim() } + .firstOrNull { it.startsWith("$FANBOX_SESSION_COOKIE=") } + ?.substringAfter("=") + ?.takeIf { it.isNotBlank() } diff --git a/web/src/lib/i18n/uiStrings.ts b/web/src/lib/i18n/uiStrings.ts index f0b0d11a3..cfd5232e8 100644 --- a/web/src/lib/i18n/uiStrings.ts +++ b/web/src/lib/i18n/uiStrings.ts @@ -97,6 +97,10 @@ export function localizedUiString(value: UiStrings): string { return 'Illustrations'; case 'Manga': return 'Manga'; + case 'FanboxSupported': + return 'Supported posts'; + case 'FanboxRecommendedCreators': + return 'Recommended creators'; default: return m.loginCredentialImport(); } From 6e6eebcaa32c9121e56ad5fc45a1a5366ada3581 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sat, 20 Jun 2026 21:08:02 +0900 Subject: [PATCH 2/9] update article media view --- .../dev/dimension/flare/ui/route/Route.kt | 9 + .../dev/dimension/flare/ui/route/Router.kt | 2 +- .../ui/screen/article/ArticleEntryBuilder.kt | 2 + .../flare/ui/screen/article/ArticleScreen.kt | 81 ++- .../ui/screen/media/MediaEntryBuilder.kt | 23 + .../flare/ui/screen/media/MediaScreen.kt | 485 +++--------------- .../ui/screen/media/StatusMediaScreen.kt | 463 +++++++++-------- .../data/network/fanbox/FanboxService.kt | 4 +- .../network/fanbox/api/FanboxResources.kt | 7 +- 9 files changed, 432 insertions(+), 644 deletions(-) diff --git a/app/src/main/java/dev/dimension/flare/ui/route/Route.kt b/app/src/main/java/dev/dimension/flare/ui/route/Route.kt index 9e21eb0e3..b994d2d6c 100644 --- a/app/src/main/java/dev/dimension/flare/ui/route/Route.kt +++ b/app/src/main/java/dev/dimension/flare/ui/route/Route.kt @@ -2,12 +2,14 @@ package dev.dimension.flare.ui.route import androidx.compose.runtime.Immutable import androidx.navigation3.runtime.NavKey +import dev.dimension.flare.common.SerializableImmutableList import dev.dimension.flare.data.model.tab.UiSourceTimelineTabItem import dev.dimension.flare.data.model.tab.UiTimelineTabItem import dev.dimension.flare.data.model.tab.xqtDeviceFollow import dev.dimension.flare.feature.agent.localhistory.LocalHistoryAgentTarget import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiMedia import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.toImmutableMap import kotlinx.serialization.Serializable @@ -441,6 +443,13 @@ internal sealed interface Route : NavKey { val customHeaders: Map? = null, ) : Media + @Serializable + data class RawMedia( + val medias: SerializableImmutableList, + val index: Int = 0, + val preview: String? = null, + ) : Media + @Serializable data class StatusMedia( val statusKey: MicroBlogKey, diff --git a/app/src/main/java/dev/dimension/flare/ui/route/Router.kt b/app/src/main/java/dev/dimension/flare/ui/route/Router.kt index 76c74de4f..8c5ec523b 100644 --- a/app/src/main/java/dev/dimension/flare/ui/route/Router.kt +++ b/app/src/main/java/dev/dimension/flare/ui/route/Router.kt @@ -97,7 +97,7 @@ internal fun Router( entryProvider = entryProvider { homeEntryBuilder(navigate, onBack, openDrawer, uriHandler = uriHandler) - articleEntryBuilder(onBack) + articleEntryBuilder(navigate, onBack) blueskyEntryBuilder(navigate, onBack) composeEntryBuilder(navigate, onBack) dmEntryBuilder(navigate, onBack) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/article/ArticleEntryBuilder.kt b/app/src/main/java/dev/dimension/flare/ui/screen/article/ArticleEntryBuilder.kt index db0963da5..5d4b7441f 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/article/ArticleEntryBuilder.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/article/ArticleEntryBuilder.kt @@ -5,12 +5,14 @@ import androidx.navigation3.runtime.NavKey import dev.dimension.flare.ui.route.Route internal fun EntryProviderScope.articleEntryBuilder( + navigate: (Route) -> Unit, onBack: () -> Unit, ) { entry { args -> ArticleScreen( accountType = args.accountType, articleKey = args.articleKey, + navigate = navigate, onBack = onBack, ) } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/article/ArticleScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/article/ArticleScreen.kt index 71643a5ff..328ee5b53 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/article/ArticleScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/article/ArticleScreen.kt @@ -89,11 +89,12 @@ import dev.dimension.flare.ui.model.takeSuccess import dev.dimension.flare.ui.presenter.article.ArticlePresenter import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.render.UiDateTime -import dev.dimension.flare.ui.route.DeeplinkRoute -import dev.dimension.flare.ui.route.toUri +import dev.dimension.flare.ui.route.Route import dev.dimension.flare.ui.theme.isLightTheme import dev.dimension.flare.ui.theme.screenHorizontalPadding import io.ktor.http.Url +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import moe.tlaster.precompose.molecule.producePresenter private val ArticleCoverHeight = 260.dp @@ -105,6 +106,7 @@ private const val ARTICLE_HEADER_KEY = "header" internal fun ArticleScreen( accountType: AccountType, articleKey: MicroBlogKey, + navigate: (Route) -> Unit, onBack: () -> Unit, ) { var refreshKey by remember(accountType, articleKey) { mutableIntStateOf(0) } @@ -277,6 +279,19 @@ internal fun ArticleScreen( titleHeightPx = it }, onOpenUrl = uriHandler::openUri, + onOpenMedia = { media -> + val articleMedias = articleState.data.articleMedias() + val index = + articleMedias.indexOf(media).takeIf { it >= 0 } + ?: articleMedias.indexOfFirst { it.url == media.url }.coerceAtLeast(0) + navigate( + Route.Media.RawMedia( + medias = articleMedias, + index = index, + preview = media.previewUrl(), + ), + ) + }, ) } @@ -316,6 +331,7 @@ private fun ArticleSuccessContent( onProfileClick: (UiProfile) -> Unit, onTitleMeasured: (Int) -> Unit, onOpenUrl: (String) -> Unit, + onOpenMedia: (UiMedia) -> Unit, ) { val layoutDirection = LocalLayoutDirection.current val listContentPadding = @@ -342,6 +358,7 @@ private fun ArticleSuccessContent( ArticleCover( cover = cover, title = article.title, + onOpenMedia = onOpenMedia, ) } } @@ -367,6 +384,7 @@ private fun ArticleSuccessContent( ArticleBlock( block = block, onOpenUrl = onOpenUrl, + onOpenMedia = onOpenMedia, ) } } @@ -378,6 +396,7 @@ private fun ArticleSuccessContent( private fun ArticleCover( cover: UiMedia.Image, title: String, + onOpenMedia: (UiMedia) -> Unit, ) { NetworkImage( model = cover.url, @@ -387,7 +406,10 @@ private fun ArticleCover( modifier = Modifier .fillMaxWidth() - .height(ArticleCoverHeight), + .height(ArticleCoverHeight) + .clickable { + onOpenMedia(cover) + }, ) } @@ -525,6 +547,7 @@ private fun ArticleRssAuthor( private fun ArticleBlock( block: UiArticleBlock, onOpenUrl: (String) -> Unit, + onOpenMedia: (UiMedia) -> Unit, ) { when (block) { is UiArticleBlock.Text -> { @@ -537,12 +560,15 @@ private fun ArticleBlock( is UiArticleBlock.Image -> { ArticleImageBlock( media = block.media, - onOpenUrl = onOpenUrl, + onOpenMedia = onOpenMedia, ) } is UiArticleBlock.Video -> { - ArticleVideoBlock(media = block.media) + ArticleVideoBlock( + media = block.media, + onOpenMedia = onOpenMedia, + ) } is UiArticleBlock.File -> { @@ -571,7 +597,7 @@ private fun ArticleBlock( @Composable private fun ArticleImageBlock( media: UiMedia.Image, - onOpenUrl: (String) -> Unit, + onOpenMedia: (UiMedia) -> Unit, ) { NetworkImage( model = media.url, @@ -583,22 +609,20 @@ private fun ArticleImageBlock( .aspectRatio(media.aspectRatio.coerceIn(0.2f, 4f)) .clip(MaterialTheme.shapes.medium) .clickable { - onOpenUrl( - DeeplinkRoute - .Media - .Image( - uri = media.url, - previewUrl = media.previewUrl, - customHeaders = media.customHeaders, - ).toUri(), - ) + onOpenMedia(media) }, ) } @Composable -private fun ArticleVideoBlock(media: UiMedia.Video) { +private fun ArticleVideoBlock( + media: UiMedia.Video, + onOpenMedia: (UiMedia) -> Unit, +) { ElevatedCard( + onClick = { + onOpenMedia(media) + }, modifier = Modifier.fillMaxWidth(), ) { Box( @@ -611,7 +635,7 @@ private fun ArticleVideoBlock(media: UiMedia.Video) { VideoPlayer( uri = media.url, customHeaders = media.customHeaders, - previewUri = media.url, + previewUri = media.thumbnailUrl, contentDescription = media.description, modifier = Modifier.fillMaxSize(), muted = false, @@ -619,6 +643,9 @@ private fun ArticleVideoBlock(media: UiMedia.Video) { keepScreenOn = false, aspectRatio = media.aspectRatio.coerceIn(0.2f, 4f), contentScale = ContentScale.Fit, + onClick = { + onOpenMedia(media) + }, autoPlay = false, ) } @@ -745,6 +772,26 @@ private fun ArticleEmbedBlockContent(block: UiArticleBlock.Embed) { } } +private fun UiArticle.articleMedias(): ImmutableList = + buildList { + cover?.let(::add) + content.blocks.forEach { block -> + when (block) { + is UiArticleBlock.Image -> add(block.media) + is UiArticleBlock.Video -> add(block.media) + else -> Unit + } + } + }.toImmutableList() + +private fun UiMedia.previewUrl(): String? = + when (this) { + is UiMedia.Audio -> previewUrl + is UiMedia.Gif -> previewUrl + is UiMedia.Image -> previewUrl + is UiMedia.Video -> thumbnailUrl + } + @Composable private fun ArticleContentGateBlock( block: UiArticleBlock.ContentGate, diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/media/MediaEntryBuilder.kt b/app/src/main/java/dev/dimension/flare/ui/screen/media/MediaEntryBuilder.kt index eb289b7eb..626890bcb 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/media/MediaEntryBuilder.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/media/MediaEntryBuilder.kt @@ -28,6 +28,29 @@ internal fun EntryProviderScope.mediaEntryBuilder( previewUrl = args.previewUrl, customHeaders = args.customHeaders, onDismiss = onBack, + toAltText = { media -> + media.description?.let { navigate(Route.Status.AltText(it)) } + }, + ) + } + + entry( + metadata = DialogSceneStrategy.dialog( + dialogProperties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ) + ) + ) { args -> + RawMediaScreen( + medias = args.medias, + index = args.index, + preview = args.preview, + onDismiss = onBack, + toAltText = { media -> + media.description?.let { navigate(Route.Status.AltText(it)) } + }, + uriHandler = uriHandler, ) } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/media/MediaScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/media/MediaScreen.kt index 67a4c0f80..160c1d280 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/media/MediaScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/media/MediaScreen.kt @@ -1,451 +1,92 @@ package dev.dimension.flare.ui.screen.media -import android.Manifest import android.content.ContentValues import android.content.Context -import android.content.Intent import android.graphics.BitmapFactory import android.os.Build import android.os.Environment import android.provider.MediaStore -import android.widget.Toast -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ListItem -import androidx.compose.material3.ListItemDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.core.content.FileProvider -import coil3.annotation.ExperimentalCoilApi -import coil3.imageLoader -import coil3.network.NetworkHeaders -import coil3.network.httpHeaders -import coil3.request.ImageRequest -import coil3.request.crossfade -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState -import compose.icons.FontAwesomeIcons -import compose.icons.fontawesomeicons.Solid -import compose.icons.fontawesomeicons.solid.Compress -import compose.icons.fontawesomeicons.solid.Download -import compose.icons.fontawesomeicons.solid.Expand -import compose.icons.fontawesomeicons.solid.ShareNodes -import compose.icons.fontawesomeicons.solid.Xmark -import dev.dimension.flare.R -import dev.dimension.flare.ui.component.FAIcon -import dev.dimension.flare.ui.component.Glassify -import dev.dimension.flare.ui.theme.FlareTheme -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import me.saket.telephoto.zoomable.ZoomSpec -import me.saket.telephoto.zoomable.coil3.ZoomableAsyncImage -import me.saket.telephoto.zoomable.rememberZoomableImageState -import me.saket.telephoto.zoomable.rememberZoomableState -import moe.tlaster.precompose.molecule.producePresenter -import moe.tlaster.swiper.Swiper -import moe.tlaster.swiper.rememberSwiperState -import org.koin.compose.koinInject +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler +import dev.dimension.flare.ui.model.UiMedia +import dev.dimension.flare.ui.model.UiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableMap import java.io.File import java.io.FileOutputStream import java.io.IOException -@OptIn( - ExperimentalMaterial3Api::class, - ExperimentalPermissionsApi::class, - ExperimentalFoundationApi::class, -) @Composable internal fun MediaScreen( uri: String, previewUrl: String?, customHeaders: Map?, onDismiss: () -> Unit, + toAltText: (UiMedia) -> Unit = {}, ) { - val hapticFeedback = LocalHapticFeedback.current - val context = LocalContext.current - val permissionState = - rememberPermissionState( - Manifest.permission.WRITE_EXTERNAL_STORAGE, - ) - - val state by producePresenter(uri) { - mediaPresenter(uri, context) - } - MediaLandscapeEffect( - enabled = state.isLandscapeViewing, - originalOrientation = state.originalOrientation, - setOriginalOrientation = state::setOriginalOrientation, + RawMediaScreen( + medias = + persistentListOf( + UiMedia.Image( + url = uri, + previewUrl = previewUrl ?: uri, + description = null, + height = 0f, + width = 0f, + sensitive = false, + customHeaders = customHeaders?.toImmutableMap(), + ), + ), + index = 0, + preview = previewUrl, + onDismiss = onDismiss, + toAltText = toAltText, + uriHandler = LocalUriHandler.current, ) - FlareTheme( - darkTheme = true, - ) { - val swiperState = - rememberSwiperState( - onDismiss = onDismiss, - ) - Box( - modifier = - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background.copy(alpha = 1 - swiperState.progress)) - .alpha(1 - swiperState.progress), - ) { - Swiper( - state = swiperState, - ) { - val zoomableState = - rememberZoomableImageState(rememberZoomableState(zoomSpec = ZoomSpec(maxZoomFactor = 10f))) - ZoomableAsyncImage( - model = - ImageRequest - .Builder(LocalContext.current) - .data(uri) - .placeholderMemoryCacheKey(previewUrl) - .crossfade(1_000) - .let { builder -> - if (customHeaders.isNullOrEmpty()) { - builder - } else { - builder.httpHeaders( - NetworkHeaders - .Builder() - .apply { - customHeaders.forEach { (key, value) -> - set(key, value) - } - }.build(), - ) - } - }.build(), - contentDescription = null, - contentScale = ContentScale.Fit, - alignment = Alignment.Center, - state = zoomableState, - onClick = { - state.setShowUi(!state.showUi) - }, - onLongClick = { - hapticFeedback.performHapticFeedback( - HapticFeedbackType.LongPress, - ) - state.setShowSheet(true) - }, - modifier = - Modifier - .fillMaxSize(), - ) - } - AnimatedVisibility( - visible = state.showUi, - modifier = - Modifier - .fillMaxWidth() - .systemBarsPadding() - .align(Alignment.TopCenter), - enter = slideInVertically { -it }, - exit = slideOutVertically { -it }, - ) { - Row( - modifier = - Modifier - .systemBarsPadding() - .padding(horizontal = 4.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Glassify( - onClick = { - onDismiss.invoke() - }, - modifier = Modifier.size(40.dp), - shape = CircleShape, - color = MaterialTheme.colorScheme.secondaryContainer, - ) { - FAIcon( - FontAwesomeIcons.Solid.Xmark, - contentDescription = stringResource(id = R.string.navigate_back), - ) - } - Spacer(Modifier.weight(1f)) - Glassify( - onClick = { - state.setLandscapeViewing(!state.isLandscapeViewing) - }, - color = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.size(40.dp), - shape = CircleShape, - ) { - FAIcon( - if (state.isLandscapeViewing) { - FontAwesomeIcons.Solid.Compress - } else { - FontAwesomeIcons.Solid.Expand - }, - contentDescription = if (state.isLandscapeViewing) "Exit landscape view" else "Landscape view", - ) - } - Glassify( - onClick = { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - if (!permissionState.status.isGranted) { - permissionState.launchPermissionRequest() - } else { - state.save() - } - } else { - state.save() - } - }, - color = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.size(40.dp), - shape = CircleShape, - ) { - FAIcon( - FontAwesomeIcons.Solid.Download, - contentDescription = stringResource(id = R.string.media_menu_save), - ) - } - Glassify( - onClick = { - state.share() - }, - color = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.size(40.dp), - shape = CircleShape, - ) { - FAIcon( - FontAwesomeIcons.Solid.ShareNodes, - contentDescription = stringResource(id = R.string.media_menu_share_image), - ) - } - } - } - } - - if (state.showSheet) { - ModalBottomSheet( - onDismissRequest = { - state.setShowSheet(false) - }, - ) { - ListItem( - headlineContent = { - Text(stringResource(id = R.string.media_menu_save)) - }, - leadingContent = { - FAIcon( - FontAwesomeIcons.Solid.Download, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - }, - modifier = - Modifier - .clickable { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - if (!permissionState.status.isGranted) { - permissionState.launchPermissionRequest() - } else { - state.save() - } - } else { - state.save() - } - state.setShowSheet(false) - }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - ListItem( - headlineContent = { - Text(stringResource(id = R.string.media_menu_share_image)) - }, - leadingContent = { - FAIcon( - FontAwesomeIcons.Solid.ShareNodes, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - }, - modifier = - Modifier - .clickable { - state.share() - state.setShowSheet(false) - }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - } - } } -@OptIn(ExperimentalCoilApi::class) @Composable -private fun mediaPresenter( - uri: String, - context: Context, - scope: CoroutineScope = koinInject(), -) = run { - var showUi by remember { - mutableStateOf(true) - } - var showSheet by remember { - mutableStateOf(false) - } - var isLandscapeViewing by remember { - mutableStateOf(false) - } - var originalOrientation: Int? by remember { - mutableStateOf(null) - } - object { - val showUi = showUi - - val showSheet = showSheet - val isLandscapeViewing = isLandscapeViewing - val originalOrientation = originalOrientation - - fun setShowSheet(value: Boolean) { - showSheet = value - } - - fun setShowUi(value: Boolean) { - showUi = value - } - - fun setLandscapeViewing(value: Boolean) { - isLandscapeViewing = value - } - - fun setOriginalOrientation(value: Int?) { - originalOrientation = value - } - - fun save() { - scope.launch { - context.imageLoader.diskCache?.openSnapshot(uri)?.use { - val byteArray = it.data.toFile().readBytes() - var fileName = uri.substringBefore("?").substringBefore("#").substringAfterLast("/") - val lastAt = fileName.lastIndexOf('@') - val lastDot = fileName.lastIndexOf('.') - if (lastAt > lastDot && lastAt < fileName.length - 1) { - fileName = fileName.substring(0, lastAt) + "." + fileName.substring(lastAt + 1) - } - if (fileName.isEmpty()) { - fileName = "image" - } - if (!fileName.contains(".")) { - val extension = - android.webkit.MimeTypeMap - .getSingleton() - .getExtensionFromMimeType(getMimeType(byteArray)) ?: "jpg" - fileName = "$fileName.$extension" - } - saveByteArrayToDownloads(context, byteArray, fileName) - } - withContext(Dispatchers.Main) { - Toast - .makeText( - context, - context.getString(R.string.media_save_success), - Toast.LENGTH_SHORT, - ).show() - } - } - } +internal fun RawMediaScreen( + medias: ImmutableList, + index: Int, + preview: String?, + onDismiss: () -> Unit, + toAltText: (UiMedia) -> Unit, + uriHandler: UriHandler, +) { + MediaViewerScreen( + medias = UiState.Success(medias), + initialIndex = index.coerceIn(0, (medias.size - 1).coerceAtLeast(0)), + preview = preview, + onDismiss = onDismiss, + toAltText = toAltText, + uriHandler = uriHandler, + fileName = { it.rawMediaFileName() }, + ) +} - fun share() { - scope.launch { - context.imageLoader.diskCache?.openSnapshot(uri)?.use { - val originFile = it.data.toFile() - var fileName = uri.substringBefore("?").substringBefore("#").substringAfterLast("/") - val lastAt = fileName.lastIndexOf('@') - val lastDot = fileName.lastIndexOf('.') - if (lastAt > lastDot && lastAt < fileName.length - 1) { - fileName = fileName.substring(0, lastAt) + "." + fileName.substring(lastAt + 1) - } - if (fileName.isEmpty()) { - fileName = "image" - } - if (!fileName.contains(".")) { - val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeFile(originFile.absolutePath, options) - val extension = - android.webkit.MimeTypeMap - .getSingleton() - .getExtensionFromMimeType(options.outMimeType) ?: "jpg" - fileName = "$fileName.$extension" - } - val targetFile = - File( - context.cacheDir, - fileName, - ) - originFile.copyTo(targetFile, overwrite = true) - val uri = - FileProvider.getUriForFile( - context, - context.packageName + ".provider", - targetFile, - ) - val intent = - Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_STREAM, uri) - setDataAndType( - uri, - "image/*", - ) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - context.startActivity( - Intent.createChooser( - intent, - context.getString(R.string.media_menu_share_image), - ), - ) - } - } +private fun UiMedia.rawMediaFileName(): String { + val fallbackExtension = + when (this) { + is UiMedia.Audio -> "mp3" + is UiMedia.Gif -> "gif" + is UiMedia.Image -> "jpg" + is UiMedia.Video -> "mp4" } + val path = url.substringBefore("?").substringBefore("#") + var fileName = path.substringAfterLast("/").substringAfterLast("\\") + val lastAtIndex = fileName.lastIndexOf('@') + val lastDotIndex = fileName.lastIndexOf('.') + if (lastAtIndex > lastDotIndex && lastAtIndex < fileName.length - 1) { + fileName = fileName.substring(0, lastAtIndex) + "." + fileName.substring(lastAtIndex + 1) + } + fileName = fileName.ifBlank { "media" }.replace(Regex("[^A-Za-z0-9._-]"), "_") + return if (fileName.contains(".")) { + fileName + } else { + "$fileName.$fallbackExtension" } } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt index b1cadd431..4855d8e8d 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt @@ -176,6 +176,47 @@ internal fun StatusMediaScreen( toAltText: (UiMedia) -> Unit, uriHandler: UriHandler, surfaceBindingManager: SurfaceBindingManager = koinInject(), +) { + val state by producePresenter { + statusMediaPresenter( + statusKey = statusKey, + accountType = accountType, + ) + } + val status = state.status.takeSuccess() as? UiTimelineV2.Post + MediaViewerScreen( + medias = state.medias, + initialIndex = index, + preview = preview, + onDismiss = onDismiss, + toAltText = toAltText, + uriHandler = uriHandler, + fileName = { media -> + media.getFileName( + statusKey = statusKey.toString(), + userHandle = status?.user?.handle?.canonical ?: "unknown", + ) + }, + status = status, + surfaceBindingManager = surfaceBindingManager, + ) +} + +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalPermissionsApi::class, +) +@Composable +internal fun MediaViewerScreen( + medias: UiState>, + initialIndex: Int, + preview: String?, + onDismiss: () -> Unit, + toAltText: (UiMedia) -> Unit, + uriHandler: UriHandler, + fileName: (UiMedia) -> String, + status: UiTimelineV2.Post? = null, + surfaceBindingManager: SurfaceBindingManager = koinInject(), ) { val scope = rememberCoroutineScope() val clipboard = LocalClipboard.current @@ -186,22 +227,21 @@ internal fun StatusMediaScreen( rememberPermissionState( Manifest.permission.WRITE_EXTERNAL_STORAGE, ) - val state by producePresenter { - statusMediaPresenter( - statusKey = statusKey, - initialIndex = index, + val state = + mediaViewerPresenter( + medias = medias, + initialIndex = initialIndex, context = context, - accountType = accountType, + fileName = fileName, ) - } val pagerState = rememberPagerState( - initialPage = index, + initialPage = initialIndex, pageCount = { when (val medias = state.medias) { is UiState.Error -> 1 is UiState.Loading -> 1 - is UiState.Success -> medias.data.size + is UiState.Success -> medias.data.size.coerceAtLeast(1) } }, ) @@ -254,12 +294,7 @@ internal fun StatusMediaScreen( } is UiState.Success -> { - when (val item = medias.data[it]) { - is UiMedia.Audio -> item.previewUrl - is UiMedia.Gif -> item.previewUrl - is UiMedia.Image -> item.previewUrl - is UiMedia.Video -> item.thumbnailUrl - } + medias.data.getOrNull(it)?.previewKey() } } ?: it }, @@ -272,7 +307,17 @@ internal fun StatusMediaScreen( ) { it .onSuccess { medias -> - val media = medias[index] + val media = medias.getOrNull(index) + if (media == null) { + Box( + modifier = + Modifier + .aspectRatio(1f) + .fillMaxSize() + .placeholder(true), + ) + return@onSuccess + } val imageUrl = when (media) { is UiMedia.Audio -> media.previewUrl ?: media.url @@ -428,7 +473,7 @@ internal fun StatusMediaScreen( } Spacer(modifier = Modifier.weight(1f)) state.medias.onSuccess { medias -> - val current = medias[state.currentPage] + val current = medias.getOrNull(state.currentPage) ?: return@onSuccess Glassify( onClick = { state.setLandscapeViewing(!state.isLandscapeViewing) @@ -501,152 +546,116 @@ internal fun StatusMediaScreen( } } - state.status.onSuccess { status -> - val content = status as? UiTimelineV2.Post - if (content is UiTimelineV2.Post) { - val currentMedia = state.medias.takeSuccess()?.getOrNull(state.currentPage) - val isCurrentVideo = currentMedia is UiMedia.Video - val shouldShowBottomUi = - when { - state.isLandscapeViewing -> { - isCurrentVideo && - (state.showUi || playbackSpeed > NORMAL_PLAYBACK_SPEED) - } + val currentMedia = state.medias.takeSuccess()?.getOrNull(state.currentPage) + val isCurrentVideo = currentMedia is UiMedia.Video + val shouldShowBottomUi = + when { + state.isLandscapeViewing -> { + isCurrentVideo && + (state.showUi || playbackSpeed > NORMAL_PLAYBACK_SPEED) + } - isCurrentVideo -> { - state.showUi || playbackSpeed > NORMAL_PLAYBACK_SPEED - } + isCurrentVideo -> { + state.showUi || playbackSpeed > NORMAL_PLAYBACK_SPEED + } - else -> { - state.showUi - } - } - androidx.compose.animation.AnimatedVisibility( - visible = shouldShowBottomUi, + else -> { + state.showUi && + (pagerState.pageCount > 1 || status != null) + } + } + androidx.compose.animation.AnimatedVisibility( + visible = shouldShowBottomUi, + modifier = + Modifier + .align(Alignment.BottomCenter), + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + ) { + Glassify( + modifier = + Modifier + .let { + if (isBigScreen) { + it + .safeContentPadding() + .clip( + MaterialTheme.shapes.medium, + ) + } else { + it + .fillMaxWidth() + } + }, + color = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.onBackground, + ) { + Column( modifier = - Modifier - .align(Alignment.BottomCenter), - enter = slideInVertically { it }, - exit = slideOutVertically { it }, + Modifier.let { + if (status == null && !isBigScreen) { + it.windowInsetsPadding( + WindowInsets.systemBars.only( + WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom, + ), + ) + } else { + it + } + }, + horizontalAlignment = Alignment.CenterHorizontally, ) { - Glassify( - modifier = - Modifier - .let { + if (state.showUi && !state.isLandscapeViewing && pagerState.pageCount > 1) { + Row( + modifier = + Modifier.let { if (isBigScreen) { it - .safeContentPadding() - .clip( - MaterialTheme.shapes.medium, - ) } else { - it.fillMaxWidth() + it.padding(top = 8.dp) } }, - color = MaterialTheme.colorScheme.surfaceContainer, - contentColor = MaterialTheme.colorScheme.onBackground, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, + horizontalArrangement = Arrangement.Center, ) { - if (state.showUi && !state.isLandscapeViewing && pagerState.pageCount > 1) { - Row( - modifier = - Modifier.let { - if (isBigScreen) { - it - } else { - it.padding(top = 8.dp) - } - }, - horizontalArrangement = Arrangement.Center, - ) { - repeat(pagerState.pageCount) { iteration -> - val color = - if (pagerState.currentPage == iteration) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onBackground.copy( - alpha = 0.5f, - ) - } - Box( - modifier = - Modifier - .padding(2.dp) - .clip(CircleShape) - .background(color) - .size(8.dp), + repeat(pagerState.pageCount) { iteration -> + val color = + if (pagerState.currentPage == iteration) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground.copy( + alpha = 0.5f, ) } - } - } - state.medias.onSuccess { medias -> - val current = - remember( - medias, - state.currentPage, - ) { - medias[state.currentPage] - } - if (current is UiMedia.Video) { - PlayerControl( - surfaceBindingManager.player, - playbackSpeed = playbackSpeed, - modifier = - Modifier - .widthIn(max = 480.dp), - ) - } + Box( + modifier = + Modifier + .padding(2.dp) + .clip(CircleShape) + .background(color) + .size(8.dp), + ) } - if (!isBigScreen && state.showUi && !state.isLandscapeViewing) { - CompositionLocalProvider( - LocalTimelineAppearance provides - LocalTimelineAppearance.current.copy( - showMedia = false, - showLinkPreview = false, - ), - LocalUriHandler provides uriHandler, - ) { - CommonStatusComponent( - item = content, - showMedia = false, - modifier = - Modifier - .padding( - horizontal = screenHorizontalPadding, - vertical = 8.dp, - ).windowInsetsPadding( - WindowInsets.systemBars.only( - WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom, - ), - ), - maxLines = 3, - showExpandButton = false, - isQuote = true, - ) - } + } + } + state.medias.onSuccess { medias -> + val current = + remember( + medias, + state.currentPage, + ) { + medias.getOrNull(state.currentPage) } + if (current is UiMedia.Video) { + PlayerControl( + surfaceBindingManager.player, + playbackSpeed = playbackSpeed, + modifier = + Modifier + .widthIn(max = 480.dp), + ) } } - } - } - } - } - if (isBigScreen) { - AnimatedVisibility(state.showUi && !state.isLandscapeViewing) { - Surface( - modifier = - Modifier - .width(320.dp) - .fillMaxHeight() - .verticalScroll(rememberScrollState()), - color = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface, - ) { - state.status.onSuccess { - val content = it as? UiTimelineV2.Post - if (content is UiTimelineV2.Post) { + if (status != null && !isBigScreen && state.showUi && !state.isLandscapeViewing) { CompositionLocalProvider( LocalTimelineAppearance provides LocalTimelineAppearance.current.copy( @@ -656,7 +665,7 @@ internal fun StatusMediaScreen( LocalUriHandler provides uriHandler, ) { CommonStatusComponent( - item = content, + item = status, showMedia = false, modifier = Modifier @@ -665,13 +674,12 @@ internal fun StatusMediaScreen( vertical = 8.dp, ).windowInsetsPadding( WindowInsets.systemBars.only( - WindowInsetsSides.End + WindowInsetsSides.Vertical, + WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom, ), ), - maxLines = Int.MAX_VALUE, + maxLines = 3, showExpandButton = false, - isQuote = false, - isDetail = true, + isQuote = true, ) } } @@ -679,6 +687,47 @@ internal fun StatusMediaScreen( } } } + if (isBigScreen && status != null) { + AnimatedVisibility(state.showUi && !state.isLandscapeViewing) { + Surface( + modifier = + Modifier + .width(320.dp) + .fillMaxHeight() + .verticalScroll(rememberScrollState()), + color = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + CompositionLocalProvider( + LocalTimelineAppearance provides + LocalTimelineAppearance.current.copy( + showMedia = false, + showLinkPreview = false, + ), + LocalUriHandler provides uriHandler, + ) { + CommonStatusComponent( + item = status, + showMedia = false, + modifier = + Modifier + .padding( + horizontal = screenHorizontalPadding, + vertical = 8.dp, + ).windowInsetsPadding( + WindowInsets.systemBars.only( + WindowInsetsSides.End + WindowInsetsSides.Vertical, + ), + ), + maxLines = Int.MAX_VALUE, + showExpandButton = false, + isQuote = false, + isDetail = true, + ) + } + } + } + } } } } @@ -707,12 +756,12 @@ internal fun StatusMediaScreen( permissionState.launchPermissionRequest() } else { state.medias.onSuccess { medias -> - state.save(medias[state.currentPage]) + medias.getOrNull(state.currentPage)?.let(state::save) } } } else { state.medias.onSuccess { medias -> - state.save(medias[state.currentPage]) + medias.getOrNull(state.currentPage)?.let(state::save) } } state.setShowSheet(false) @@ -723,7 +772,8 @@ internal fun StatusMediaScreen( ), ) state.medias.onSuccess { medias -> - if (medias[state.currentPage] is UiMedia.Image) { + val current = medias.getOrNull(state.currentPage) + if (current is UiMedia.Image) { ListItem( headlineContent = { Text(stringResource(id = R.string.media_menu_share_image)) @@ -738,7 +788,7 @@ internal fun StatusMediaScreen( modifier = Modifier .clickable { - state.shareMedia(medias[state.currentPage]) + state.shareMedia(current) state.setShowSheet(false) }, colors = @@ -751,6 +801,7 @@ internal fun StatusMediaScreen( state.medias.onSuccess { medias -> val label = stringResource(R.string.media_menu_media_link) + val current = medias.getOrNull(state.currentPage) ?: return@onSuccess ListItem( headlineContent = { Text(stringResource(id = R.string.media_menu_copy_link)) @@ -766,7 +817,7 @@ internal fun StatusMediaScreen( Modifier .clickable { scope.launch { - val url = medias[state.currentPage].url + val url = current.url clipboard.setClipEntry( ClipEntry( ClipData.newRawUri( @@ -1110,13 +1161,16 @@ private fun ImageItem( setLockPager: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { + val currentOnClick by rememberUpdatedState(onClick) + val currentOnLongClick by rememberUpdatedState(onLongClick) + val currentSetLockPager by rememberUpdatedState(setLockPager) val scope = rememberCoroutineScope() val zoomableState = rememberZoomableState(zoomSpec = ZoomSpec(maxZoomFactor = 10f)) LaunchedEffect(zoomableState.zoomFraction) { zoomableState.zoomFraction?.let { - setLockPager(it > 0.01f) - } ?: setLockPager(false) + currentSetLockPager(it > 0.01f) + } ?: currentSetLockPager(false) } BackHandler( enabled = (zoomableState.zoomFraction ?: 0f) > 0.01f, @@ -1177,48 +1231,36 @@ private fun ImageItem( contentScale = contentScale, alignment = alignment, onClick = { - onClick.invoke() + currentOnClick() }, onLongClick = { - onLongClick.invoke() + currentOnLongClick() }, onDoubleClick = DoubleClickToZoomListener.cycle(2f), ) } -@OptIn(ExperimentalCoilApi::class) +private fun UiMedia.previewKey(): String? = + when (this) { + is UiMedia.Audio -> previewUrl + is UiMedia.Gif -> previewUrl + is UiMedia.Image -> previewUrl + is UiMedia.Video -> thumbnailUrl + } + @Composable private fun statusMediaPresenter( statusKey: MicroBlogKey, - initialIndex: Int, - context: Context, accountType: AccountType, - scope: CoroutineScope = koinInject(), - videoDownloadHelper: VideoDownloadHelper = koinInject(), ) = run { - var showSheet by remember { - mutableStateOf(false) - } - var showUi by remember { - mutableStateOf(true) - } - var lockPager by remember { - mutableStateOf(false) - } - var isLandscapeViewing by remember { - mutableStateOf(false) - } - var originalOrientation: Int? by remember { - mutableStateOf(null) - } val state = - remember(statusKey) { + remember(accountType, statusKey) { StatusPresenter(accountType = accountType, statusKey = statusKey) }.invoke() - var medias: UiState> by remember { + var medias: UiState> by remember(accountType, statusKey) { mutableStateOf(UiState.Loading()) } - // prevent media change when medias is loaded + // Prevent media changes after the first successful load so the visible page stays stable. if (!medias.isSuccess) { LaunchedEffect(state) { state.status @@ -1233,11 +1275,41 @@ private fun statusMediaPresenter( } } } + object { + val status = state.status + val medias = medias + } +} + +@OptIn(ExperimentalCoilApi::class) +@Composable +private fun mediaViewerPresenter( + medias: UiState>, + initialIndex: Int, + context: Context, + fileName: (UiMedia) -> String, + scope: CoroutineScope = koinInject(), + videoDownloadHelper: VideoDownloadHelper = koinInject(), +) = run { + var showSheet by remember { + mutableStateOf(false) + } + var showUi by remember { + mutableStateOf(true) + } + var lockPager by remember { + mutableStateOf(false) + } + var isLandscapeViewing by remember { + mutableStateOf(false) + } + var originalOrientation: Int? by remember { + mutableStateOf(null) + } var currentPage by remember { mutableIntStateOf(initialIndex) } object { - val status = state.status val medias = medias val showUi = showUi val currentPage = currentPage @@ -1273,18 +1345,12 @@ private fun statusMediaPresenter( } fun save(data: UiMedia) { - val status = (state.status.takeSuccess() as? UiTimelineV2.Post) - if (status != null) { - val statusKeyString = statusKey.toString() - val userHandle = status.user?.handle?.canonical ?: "unknown" - val fileName = data.getFileName(statusKeyString, userHandle) - - when (data) { - is UiMedia.Audio -> download(data.url, fileName, data.customHeaders) - is UiMedia.Gif -> download(data.url, fileName, data.customHeaders) - is UiMedia.Image -> save(data.url, fileName) - is UiMedia.Video -> download(data.url, fileName, data.customHeaders) - } + val targetFileName = fileName(data) + when (data) { + is UiMedia.Audio -> download(data.url, targetFileName, data.customHeaders) + is UiMedia.Gif -> download(data.url, targetFileName, data.customHeaders) + is UiMedia.Image -> save(data.url, targetFileName) + is UiMedia.Video -> download(data.url, targetFileName, data.customHeaders) } } @@ -1302,13 +1368,10 @@ private fun statusMediaPresenter( scope.launch { context.imageLoader.diskCache?.openSnapshot(data.url)?.use { val originFile = it.data.toFile() - val status = state.status.takeSuccess() as? UiTimelineV2.Post - val statusKeyString = statusKey.toString() - val userHandle = status?.user?.handle?.canonical ?: "unknown" val targetFile = File( context.cacheDir, - data.getFileName(statusKeyString, userHandle), + fileName(data), ) originFile.copyTo(targetFile, overwrite = true) val uri = diff --git a/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/FanboxService.kt b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/FanboxService.kt index c84270e02..8cab5a084 100644 --- a/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/FanboxService.kt +++ b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/FanboxService.kt @@ -74,13 +74,13 @@ internal class FanboxService( private val webResources: FanboxWebResources = fanboxWebResources(credentialFlow) - suspend fun metadata(): FanboxMetaDataEntity = webResources.metadata().toFanboxMetadata() + suspend fun metadata(): FanboxMetaDataEntity = webResources.metadata(FANBOX_WEB_URL).toFanboxMetadata() suspend fun metadata(sessionId: String): FanboxMetaDataEntity { val html = fanboxWebResources( flowOf(FanboxCredential(sessionId = sessionId, userId = "")), - ).metadata() + ).metadata(FANBOX_WEB_URL) return html.toFanboxMetadata() } diff --git a/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/api/FanboxResources.kt b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/api/FanboxResources.kt index e4a9c4efc..eaae1da73 100644 --- a/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/api/FanboxResources.kt +++ b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/network/fanbox/api/FanboxResources.kt @@ -5,6 +5,7 @@ import de.jensklingenberg.ktorfit.http.GET import de.jensklingenberg.ktorfit.http.Header import de.jensklingenberg.ktorfit.http.POST import de.jensklingenberg.ktorfit.http.Query +import de.jensklingenberg.ktorfit.http.Url import dev.dimension.flare.data.network.fanbox.FanboxCommentListResponse import dev.dimension.flare.data.network.fanbox.FanboxCreatorDetailResponse import dev.dimension.flare.data.network.fanbox.FanboxCreatorListResponse @@ -18,8 +19,10 @@ import dev.dimension.flare.data.network.fanbox.FanboxPostListResponse import dev.dimension.flare.data.network.fanbox.FanboxPostSearchResponse internal interface FanboxWebResources { - @GET("/") - suspend fun metadata(): String + @GET + suspend fun metadata( + @Url url: String, + ): String } internal interface FanboxResources { From f69cd032f3b9c31ac519d4238d40b4ad51512e02 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sat, 20 Jun 2026 21:26:56 +0900 Subject: [PATCH 3/9] add article file download --- .../flare/ui/screen/article/ArticleScreen.kt | 113 +++++++++++++++++- .../ui/screen/gallery/GalleryDetailScreen.kt | 26 ++-- .../dev/dimension/flare/ui/model/UiArticle.kt | 2 + .../data/datasource/fanbox/FanboxMapper.kt | 8 +- 4 files changed, 129 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/article/ArticleScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/article/ArticleScreen.kt index 328ee5b53..ca1498356 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/article/ArticleScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/article/ArticleScreen.kt @@ -2,6 +2,7 @@ package dev.dimension.flare.ui.screen.article import android.content.Context import android.content.Intent +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -57,11 +58,13 @@ import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Brands import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.brands.Chrome +import compose.icons.fontawesomeicons.solid.Download import compose.icons.fontawesomeicons.solid.File import compose.icons.fontawesomeicons.solid.Globe import compose.icons.fontawesomeicons.solid.Lock import compose.icons.fontawesomeicons.solid.ShareNodes import dev.dimension.flare.R +import dev.dimension.flare.common.VideoDownloadHelper import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.component.AvatarComponentDefaults @@ -95,7 +98,12 @@ import dev.dimension.flare.ui.theme.screenHorizontalPadding import io.ktor.http.Url import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import moe.tlaster.precompose.molecule.producePresenter +import org.koin.compose.koinInject private val ArticleCoverHeight = 260.dp private const val ARTICLE_COVER_KEY = "cover" @@ -121,6 +129,8 @@ internal fun ArticleScreen( val listState = rememberLazyListState() val uriHandler = LocalUriHandler.current val context = LocalContext.current + val downloadScope = koinInject() + val videoDownloadHelper = koinInject() val article = state.article.takeSuccess() val articleTitle = article?.title var titleHeightPx by remember(article?.key) { mutableIntStateOf(0) } @@ -279,6 +289,14 @@ internal fun ArticleScreen( titleHeightPx = it }, onOpenUrl = uriHandler::openUri, + onDownloadFile = { file -> + downloadArticleFile( + block = file, + context = context, + scope = downloadScope, + videoDownloadHelper = videoDownloadHelper, + ) + }, onOpenMedia = { media -> val articleMedias = articleState.data.articleMedias() val index = @@ -331,6 +349,7 @@ private fun ArticleSuccessContent( onProfileClick: (UiProfile) -> Unit, onTitleMeasured: (Int) -> Unit, onOpenUrl: (String) -> Unit, + onDownloadFile: (UiArticleBlock.File) -> Unit, onOpenMedia: (UiMedia) -> Unit, ) { val layoutDirection = LocalLayoutDirection.current @@ -384,6 +403,7 @@ private fun ArticleSuccessContent( ArticleBlock( block = block, onOpenUrl = onOpenUrl, + onDownloadFile = onDownloadFile, onOpenMedia = onOpenMedia, ) } @@ -547,6 +567,7 @@ private fun ArticleRssAuthor( private fun ArticleBlock( block: UiArticleBlock, onOpenUrl: (String) -> Unit, + onDownloadFile: (UiArticleBlock.File) -> Unit, onOpenMedia: (UiMedia) -> Unit, ) { when (block) { @@ -574,7 +595,7 @@ private fun ArticleBlock( is UiArticleBlock.File -> { ArticleFileBlock( block = block, - onOpenUrl = onOpenUrl, + onDownloadFile = onDownloadFile, ) } @@ -655,11 +676,11 @@ private fun ArticleVideoBlock( @Composable private fun ArticleFileBlock( block: UiArticleBlock.File, - onOpenUrl: (String) -> Unit, + onDownloadFile: (UiArticleBlock.File) -> Unit, ) { ElevatedCard( onClick = { - onOpenUrl(block.url) + onDownloadFile(block) }, modifier = Modifier.fillMaxWidth(), ) { @@ -691,6 +712,12 @@ private fun ArticleFileBlock( ) } } + FAIcon( + FontAwesomeIcons.Solid.Download, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) } } } @@ -948,6 +975,86 @@ private fun ArticleBodyContainer(content: @Composable () -> Unit) { } } +private fun downloadArticleFile( + block: UiArticleBlock.File, + context: Context, + scope: CoroutineScope, + videoDownloadHelper: VideoDownloadHelper, +) { + scope.launch { + runCatching { + videoDownloadHelper.downloadVideo( + uri = block.url, + fileName = block.downloadFileName(), + customHeaders = block.customHeaders, + callback = + object : VideoDownloadHelper.DownloadCallback { + override fun onDownloadStarted(downloadId: Long) { + context.showArticleDownloadToast(R.string.media_download_started) + } + + override fun onDownloadSuccess(downloadId: Long) { + context.showArticleDownloadToast(R.string.media_save_success) + } + + override fun onDownloadFailed(downloadId: Long) { + context.showArticleDownloadToast(R.string.media_save_fail) + } + }, + ) + }.onFailure { + withContext(Dispatchers.Main) { + context.showArticleDownloadToast(R.string.media_save_fail) + } + } + } +} + +private fun UiArticleBlock.File.downloadFileName(): String { + val sourceName = + name.trim().takeIf { it.isNotBlank() } + ?: url + .substringBefore("?") + .substringBefore("#") + .substringAfterLast("/") + .trim() + .takeIf { it.isNotBlank() } + ?: "file" + val extension = extension?.trim()?.trimStart('.')?.takeIf { it.isNotBlank() } + val fileName = + if (extension != null && !sourceName.hasFileExtension()) { + "$sourceName.$extension" + } else { + sourceName + } + return fileName.toSafeDownloadFileName() +} + +private fun String.hasFileExtension(): Boolean { + val name = substringAfterLast('/').substringAfterLast('\\') + val lastDotIndex = name.lastIndexOf('.') + return lastDotIndex > 0 && lastDotIndex < name.length - 1 +} + +private fun String.toSafeDownloadFileName(): String { + val safeName = + trim() + .map { char -> + if (char == '/' || char == '\\' || char.code < 32 || char.code == 127) { + '_' + } else { + char + } + }.joinToString(separator = "") + return safeName.ifBlank { "file" } +} + +private fun Context.showArticleDownloadToast(messageRes: Int) { + Toast + .makeText(this, getString(messageRes), Toast.LENGTH_SHORT) + .show() +} + private fun shareArticle( context: Context, article: UiArticle, diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/gallery/GalleryDetailScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/gallery/GalleryDetailScreen.kt index e0f6286b1..1bda9990e 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/gallery/GalleryDetailScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/gallery/GalleryDetailScreen.kt @@ -99,6 +99,7 @@ import dev.dimension.flare.ui.presenter.gallery.GalleryDetailPresenter import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.route.Route import dev.dimension.flare.ui.theme.screenHorizontalPadding +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter @@ -304,7 +305,7 @@ private fun CompactGalleryContent( GalleryImages( detail = detail, onMediaClick = { media -> - navigate(detail.post.statusMediaRoute(media)) + navigate(detail.post.galleryMediaRoute(media)) }, modifier = Modifier @@ -367,7 +368,7 @@ private fun BigScreenGalleryContent( GalleryImage( image = image, onClick = { - navigate(detail.post.statusMediaRoute(image)) + navigate(detail.post.galleryMediaRoute(image)) }, ) } @@ -376,7 +377,7 @@ private fun BigScreenGalleryContent( GalleryImages( detail = detail, onMediaClick = { media -> - navigate(detail.post.statusMediaRoute(media)) + navigate(detail.post.galleryMediaRoute(media)) }, modifier = Modifier.fillMaxSize(), ) @@ -1101,19 +1102,14 @@ private fun UiTimelineV2.Post.countedActions(): List = private fun UiTimelineV2.Post.galleryImages(): List = images.filterIsInstance() -private fun UiTimelineV2.Post.statusMediaRoute(media: UiMedia): Route.Media.StatusMedia = - Route.Media.StatusMedia( - statusKey = statusKey, - accountType = accountType, - index = images.indexOfFirst { it.url == media.url }.coerceAtLeast(0), - preview = - when (media) { - is UiMedia.Image -> media.previewUrl - is UiMedia.Video -> media.thumbnailUrl - is UiMedia.Gif -> media.previewUrl - is UiMedia.Audio -> null - }, +private fun UiTimelineV2.Post.galleryMediaRoute(media: UiMedia.Image): Route.Media.RawMedia { + val medias = galleryImages().map { it as UiMedia }.toImmutableList() + return Route.Media.RawMedia( + medias = medias, + index = medias.indexOfFirst { it.url == media.url }.coerceAtLeast(0), + preview = media.previewUrl, ) +} @Composable private fun ActionMenu.Item.Color?.toComposeColor(): Color = diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiArticle.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiArticle.kt index 4b3832702..5a1e739a6 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiArticle.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiArticle.kt @@ -2,6 +2,7 @@ package dev.dimension.flare.ui.model import androidx.compose.runtime.Immutable import dev.dimension.flare.common.SerializableImmutableList +import dev.dimension.flare.common.SerializableImmutableMap import dev.dimension.flare.model.PlatformType import dev.dimension.flare.ui.render.RenderContent import dev.dimension.flare.ui.render.UiDateTime @@ -97,6 +98,7 @@ public sealed interface UiArticleBlock { val url: String, val sizeBytes: Long? = null, val extension: String? = null, + val customHeaders: SerializableImmutableMap? = null, ) : UiArticleBlock @Serializable diff --git a/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/datasource/fanbox/FanboxMapper.kt b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/datasource/fanbox/FanboxMapper.kt index ae7117c26..e28c07bab 100644 --- a/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/datasource/fanbox/FanboxMapper.kt +++ b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/datasource/fanbox/FanboxMapper.kt @@ -463,7 +463,7 @@ private fun FanboxPostDetailBody.FileItem.toArticleFileBlocks( if (isArticleVideoFile()) { add(toArticleVideoBlock(key = "$key:video", imageHeaders = imageHeaders)) } - add(toArticleFileBlock(key = key)) + add(toArticleFileBlock(key = key, imageHeaders = imageHeaders)) } private fun FanboxPostDetailBody.FileItem.toArticleVideoBlock( @@ -483,13 +483,17 @@ private fun FanboxPostDetailBody.FileItem.toArticleVideoBlock( ), ) -private fun FanboxPostDetailBody.FileItem.toArticleFileBlock(key: String): UiArticleBlock.File = +private fun FanboxPostDetailBody.FileItem.toArticleFileBlock( + key: String, + imageHeaders: ImmutableMap? = null, +): UiArticleBlock.File = UiArticleBlock.File( key = key, name = name, url = url, sizeBytes = size, extension = extension.takeIf { it.isNotBlank() }, + customHeaders = imageHeaders, ) private fun FanboxPostDetailBody.FileItem.isArticleVideoFile(): Boolean = articleFileExtension() in FANBOX_ARTICLE_VIDEO_EXTENSIONS From f0caa0f78e9c4d7c0a4a9a6d50286a9ddf63bf3d Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sat, 20 Jun 2026 23:00:37 +0900 Subject: [PATCH 4/9] add article view for desktop --- .../main/composeResources/values/strings.xml | 3 + .../flare/common/DesktopDownloadManager.kt | 21 +- .../dev/dimension/flare/ui/route/Route.kt | 12 + .../dev/dimension/flare/ui/route/Router.kt | 8 + .../flare/ui/screen/article/ArticleScreen.kt | 894 ++++++++++++++++++ .../serviceselect/WebViewLoginScreen.kt | 21 +- 6 files changed, 942 insertions(+), 17 deletions(-) create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/article/ArticleScreen.kt diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index ac5348a5d..b894c15a6 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -460,6 +460,9 @@ Summarize Failed to summarize Copy link + Support required + This article is only available to supporters. Open it in the browser to support the creator and read the full content. + This article is only available to supporters from ¥%1$d. Open it in the browser to support the creator and read the full content. Available timelines Trending statuses Federated timeline diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/common/DesktopDownloadManager.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/common/DesktopDownloadManager.kt index 825a5eb3e..d6840d230 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/common/DesktopDownloadManager.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/common/DesktopDownloadManager.kt @@ -7,6 +7,7 @@ import dev.dimension.flare.media_save_success import dev.dimension.flare.ui.component.ComposeInAppNotification import io.ktor.client.HttpClient import io.ktor.client.call.body +import io.ktor.client.request.header import io.ktor.client.request.prepareGet import io.ktor.client.statement.HttpResponse import io.ktor.http.HttpHeaders @@ -30,6 +31,7 @@ internal class DesktopDownloadManager( url: String, targetFile: File, overwrite: Boolean = true, + customHeaders: Map? = null, onProgress: (DownloadProgress) -> Unit = {}, ) = withContext(Dispatchers.IO) { require(url.isNotBlank()) { "url must not be blank" } @@ -46,13 +48,18 @@ internal class DesktopDownloadManager( } try { - client.prepareGet(url).execute { response -> - writeResponseToFile( - response = response, - outputFile = tempFile, - onProgress = onProgress, - ) - } + client + .prepareGet(url) { + customHeaders?.forEach { (key, value) -> + header(key, value) + } + }.execute { response -> + writeResponseToFile( + response = response, + outputFile = tempFile, + onProgress = onProgress, + ) + } moveIntoPlace(tempFile = tempFile, targetFile = targetFile, overwrite = overwrite) inAppNotification.message(Res.string.media_save_success) } catch (t: Exception) { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt index d5d64ba36..3acf79718 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt @@ -224,6 +224,11 @@ internal sealed interface Route : NavKey { val articleId: String? = null, ) : ScreenRoute + data class Article( + val accountType: AccountType, + val articleKey: MicroBlogKey, + ) : ScreenRoute + data class DmList( val accountType: AccountType, ) : ScreenRoute @@ -433,6 +438,13 @@ internal sealed interface Route : NavKey { ) } + is DeeplinkRoute.Article -> { + Article( + accountType = deeplinkRoute.accountType, + articleKey = deeplinkRoute.articleKey, + ) + } + is DeeplinkRoute.Search -> { Search( accountType = deeplinkRoute.accountType, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt index 9b8f836e2..777e897ab 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt @@ -51,6 +51,7 @@ import dev.dimension.flare.ui.route.Route.RssTimeline import dev.dimension.flare.ui.route.Route.Search import dev.dimension.flare.ui.route.Route.Timeline import dev.dimension.flare.ui.route.WindowSceneStrategy.Companion.window +import dev.dimension.flare.ui.screen.article.ArticleScreen import dev.dimension.flare.ui.screen.compose.ComposeDialog import dev.dimension.flare.ui.screen.compose.DraftBoxScreen import dev.dimension.flare.ui.screen.dm.DmConversationScreen @@ -255,6 +256,13 @@ internal fun Router( onBack = onBack, ) } + entry { args -> + ArticleScreen( + accountType = args.accountType, + articleKey = args.articleKey, + navigate = navigate, + ) + } entry { args -> dev.dimension.flare.ui.screen.rss.RssDetailScreen( url = args.url, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/article/ArticleScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/article/ArticleScreen.kt new file mode 100644 index 000000000..c3e779878 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/article/ArticleScreen.kt @@ -0,0 +1,894 @@ +package dev.dimension.flare.ui.screen.article + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposeWindow +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Brands +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.brands.Chrome +import compose.icons.fontawesomeicons.solid.Download +import compose.icons.fontawesomeicons.solid.File +import compose.icons.fontawesomeicons.solid.Globe +import compose.icons.fontawesomeicons.solid.Lock +import compose.icons.fontawesomeicons.solid.ShareNodes +import dev.dimension.flare.LocalWindowPadding +import dev.dimension.flare.Res +import dev.dimension.flare.article_content_gate_subscription_description +import dev.dimension.flare.article_content_gate_subscription_description_with_fee +import dev.dimension.flare.article_content_gate_subscription_title +import dev.dimension.flare.common.DesktopDownloadManager +import dev.dimension.flare.copied_to_clipboard +import dev.dimension.flare.media_save +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.rss_detail_open_in_browser +import dev.dimension.flare.status_share +import dev.dimension.flare.ui.component.ComposeInAppNotification +import dev.dimension.flare.ui.component.DateTimeText +import dev.dimension.flare.ui.component.ErrorContent +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.FavIcon +import dev.dimension.flare.ui.component.FlareScrollBar +import dev.dimension.flare.ui.component.NetworkImage +import dev.dimension.flare.ui.component.RichText +import dev.dimension.flare.ui.component.listCard +import dev.dimension.flare.ui.component.placeholder +import dev.dimension.flare.ui.component.status.CommonStatusHeaderComponent +import dev.dimension.flare.ui.model.UiArticle +import dev.dimension.flare.ui.model.UiArticleAuthor +import dev.dimension.flare.ui.model.UiArticleBlock +import dev.dimension.flare.ui.model.UiArticleContentGateReason +import dev.dimension.flare.ui.model.UiMedia +import dev.dimension.flare.ui.model.UiState +import dev.dimension.flare.ui.model.takeSuccess +import dev.dimension.flare.ui.presenter.article.ArticlePresenter +import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.render.UiDateTime +import dev.dimension.flare.ui.route.Route +import dev.dimension.flare.ui.screen.media.VideoItem +import dev.dimension.flare.ui.theme.LocalComposeWindow +import dev.dimension.flare.ui.theme.screenHorizontalPadding +import io.github.composefluent.FluentTheme +import io.github.composefluent.component.AccentButton +import io.github.composefluent.component.ListItemSeparator +import io.github.composefluent.component.SubtleButton +import io.github.composefluent.component.Text +import io.github.composefluent.surface.Card +import io.ktor.http.Url +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import moe.tlaster.precompose.molecule.producePresenter +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import java.awt.FileDialog +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection +import java.io.File + +private val ArticleCoverHeight = 260.dp +private const val ARTICLE_COVER_KEY = "cover" +private const val ARTICLE_HEADER_KEY = "header" + +@Composable +internal fun ArticleScreen( + accountType: AccountType, + articleKey: MicroBlogKey, + navigate: (Route) -> Unit, +) { + var refreshKey by remember(accountType, articleKey) { mutableIntStateOf(0) } + val state by producePresenter("desktop_article_$accountType-$articleKey-$refreshKey") { + remember(accountType, articleKey) { + ArticlePresenter( + accountType = accountType, + articleKey = articleKey, + ) + }.invoke() + } + val listState = rememberLazyListState() + val uriHandler = LocalUriHandler.current + val layoutDirection = LocalLayoutDirection.current + val scope = rememberCoroutineScope() + val window = LocalComposeWindow.current + val downloadManager: DesktopDownloadManager = koinInject() + val inAppNotification: ComposeInAppNotification = koinInject() + val article = state.article.takeSuccess() + val windowPadding = LocalWindowPadding.current + val contentPadding = + PaddingValues( + start = windowPadding.calculateStartPadding(layoutDirection), + top = windowPadding.calculateTopPadding() + 16.dp, + end = windowPadding.calculateEndPadding(layoutDirection), + bottom = windowPadding.calculateBottomPadding() + 16.dp, + ) + + Box( + modifier = + Modifier + .fillMaxSize() + .background(FluentTheme.colors.background.card.default), + contentAlignment = Alignment.TopCenter, + ) { + FlareScrollBar(state = listState) { + SelectionContainer { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + article?.let { currentArticle -> + currentArticle.sourceUrl?.takeIf { it.isNotBlank() }?.let { sourceUrl -> + item(key = "toolbar") { + ArticleBodyContainer { + ArticleToolbar( + sourceUrl = sourceUrl, + article = currentArticle, + onOpenUrl = uriHandler::openUri, + onShare = { + shareArticleText(it) + inAppNotification.message(Res.string.copied_to_clipboard) + }, + ) + } + } + } + } + when (val articleState = state.article) { + is UiState.Success -> { + articleSuccessItems( + article = articleState.data, + accountType = accountType, + navigate = navigate, + onOpenUrl = uriHandler::openUri, + onDownloadFile = { file -> + saveArticleFile( + block = file, + window = window, + scope = scope, + downloadManager = downloadManager, + ) + }, + ) + } + + is UiState.Loading -> { + articleLoadingItems() + } + + is UiState.Error -> { + item(key = "error") { + ArticleBodyContainer { + ErrorContent( + error = articleState.throwable, + onRetry = { + refreshKey++ + }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } + } + } + } +} + +@Composable +private fun ArticleToolbar( + sourceUrl: String, + article: UiArticle, + onOpenUrl: (String) -> Unit, + onShare: (UiArticle) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = Modifier.weight(1f)) + SubtleButton( + onClick = { + onOpenUrl(sourceUrl) + }, + iconOnly = true, + ) { + FAIcon( + FontAwesomeIcons.Brands.Chrome, + contentDescription = stringResource(Res.string.rss_detail_open_in_browser), + ) + } + SubtleButton( + onClick = { + onShare(article) + }, + iconOnly = true, + ) { + FAIcon( + FontAwesomeIcons.Solid.ShareNodes, + contentDescription = stringResource(Res.string.status_share), + ) + } + } +} + +private fun LazyListScope.articleSuccessItems( + article: UiArticle, + accountType: AccountType, + navigate: (Route) -> Unit, + onOpenUrl: (String) -> Unit, + onDownloadFile: (UiArticleBlock.File) -> Unit, +) { + article.cover?.let { cover -> + item(key = ARTICLE_COVER_KEY) { + ArticleCover( + cover = cover, + title = article.title, + onOpenMedia = { media -> + navigate(media.rawImageRoute()) + }, + ) + } + } + item(key = ARTICLE_HEADER_KEY) { + ArticleBodyContainer { + ArticleHeader( + article = article, + onProfileClick = { userKey -> + navigate( + Route.Profile( + accountType = accountType, + userKey = userKey, + ), + ) + }, + ) + } + } + item(key = "divider") { + ArticleBodyContainer { + ListItemSeparator(Modifier.fillMaxWidth()) + } + } + items( + items = article.content.blocks, + key = UiArticleBlock::key, + ) { block -> + ArticleBodyContainer { + ArticleBlock( + block = block, + onOpenUrl = onOpenUrl, + onDownloadFile = onDownloadFile, + onOpenMedia = { media -> + navigate(media.rawImageRoute()) + }, + ) + } + } +} + +@Composable +private fun ArticleCover( + cover: UiMedia.Image, + title: String, + onOpenMedia: (UiMedia.Image) -> Unit, +) { + NetworkImage( + model = cover.url, + contentDescription = title, + customHeaders = cover.customHeaders, + contentScale = ContentScale.Crop, + modifier = + Modifier + .fillMaxWidth() + .height(ArticleCoverHeight) + .listCard() + .clickable { + onOpenMedia(cover) + }, + ) +} + +@Composable +private fun ArticleHeader( + article: UiArticle, + onProfileClick: (MicroBlogKey) -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = article.title, + style = FluentTheme.typography.title, + ) + when (val author = article.author) { + is UiArticleAuthor.Profile -> { + CommonStatusHeaderComponent( + data = author.profile, + onUserClick = onProfileClick, + trailing = { + article.publishDate?.let { + DateTimeText( + data = it, + style = FluentTheme.typography.caption, + color = FluentTheme.colors.text.text.secondary, + fullTime = true, + ) + } + }, + ) + } + + is UiArticleAuthor.Rss -> { + ArticleRssAuthor( + author = author, + sourceUrl = article.sourceUrl, + publishDate = article.publishDate, + ) + } + + null -> { + article.publishDate?.let { + DateTimeText( + data = it, + style = FluentTheme.typography.caption, + color = FluentTheme.colors.text.text.secondary, + fullTime = true, + ) + } + } + } + } +} + +@Composable +private fun ArticleRssAuthor( + author: UiArticleAuthor.Rss, + sourceUrl: String?, + publishDate: UiDateTime?, +) { + if (author.siteName == null && author.byline == null && publishDate == null) { + return + } + val host = + remember(sourceUrl) { + sourceUrl?.let { + runCatching { Url(it).host }.getOrNull() + } + } + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + author.siteName?.let { siteName -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (host != null) { + FavIcon( + host = host, + size = 16.dp, + ) + } else { + author.iconUrl?.let { + NetworkImage( + model = it, + contentDescription = siteName, + modifier = Modifier.size(16.dp), + ) + } + } + Text( + text = siteName, + style = FluentTheme.typography.caption, + color = FluentTheme.colors.text.text.secondary, + ) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + author.byline?.let { + Text( + text = it, + style = FluentTheme.typography.caption, + color = FluentTheme.colors.text.text.secondary, + modifier = Modifier.weight(1f, fill = false), + ) + } + Spacer(modifier = Modifier.weight(1f)) + publishDate?.let { + DateTimeText( + data = it, + style = FluentTheme.typography.caption, + color = FluentTheme.colors.text.text.secondary, + fullTime = true, + ) + } + } + } +} + +@Composable +private fun ArticleBlock( + block: UiArticleBlock, + onOpenUrl: (String) -> Unit, + onDownloadFile: (UiArticleBlock.File) -> Unit, + onOpenMedia: (UiMedia.Image) -> Unit, +) { + when (block) { + is UiArticleBlock.Text -> { + RichText( + text = block.richText, + modifier = Modifier.fillMaxWidth(), + overflow = TextOverflow.Clip, + textStyle = FluentTheme.typography.body, + ) + } + + is UiArticleBlock.Image -> { + ArticleImageBlock( + media = block.media, + onOpenMedia = onOpenMedia, + ) + } + + is UiArticleBlock.Video -> { + ArticleVideoBlock(media = block.media) + } + + is UiArticleBlock.File -> { + ArticleFileBlock( + block = block, + onDownloadFile = onDownloadFile, + ) + } + + is UiArticleBlock.Embed -> { + ArticleEmbedBlock( + block = block, + onOpenUrl = onOpenUrl, + ) + } + + is UiArticleBlock.ContentGate -> { + ArticleContentGateBlock( + block = block, + onOpenUrl = onOpenUrl, + ) + } + } +} + +@Composable +private fun ArticleImageBlock( + media: UiMedia.Image, + onOpenMedia: (UiMedia.Image) -> Unit, +) { + NetworkImage( + model = media.url, + contentDescription = media.description, + customHeaders = media.customHeaders, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(media.aspectRatio.coerceIn(0.2f, 4f)) + .listCard() + .clickable { + onOpenMedia(media) + }, + ) +} + +@Composable +private fun ArticleVideoBlock(media: UiMedia.Video) { + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(media.aspectRatio.coerceIn(0.2f, 4f)), + contentAlignment = Alignment.Center, + ) { + VideoItem( + url = media.url, + thumbnailUrl = media.thumbnailUrl, + description = media.description, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@Composable +private fun ArticleFileBlock( + block: UiArticleBlock.File, + onDownloadFile: (UiArticleBlock.File) -> Unit, +) { + Card( + modifier = + Modifier + .fillMaxWidth() + .clickable { + onDownloadFile(block) + }, + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + FAIcon( + FontAwesomeIcons.Solid.File, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = block.name, + style = FluentTheme.typography.body, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + block.extension?.takeIf { it.isNotBlank() }?.let { + Text( + text = it.uppercase(), + style = FluentTheme.typography.caption, + color = FluentTheme.colors.text.text.secondary, + ) + } + } + FAIcon( + FontAwesomeIcons.Solid.Download, + contentDescription = stringResource(Res.string.media_save), + modifier = Modifier.size(18.dp), + tint = FluentTheme.colors.text.text.secondary, + ) + } + } +} + +@Composable +private fun ArticleEmbedBlock( + block: UiArticleBlock.Embed, + onOpenUrl: (String) -> Unit, +) { + val url = block.url + Card( + modifier = + Modifier + .fillMaxWidth() + .let { + if (url == null) { + it + } else { + it.clickable { + onOpenUrl(url) + } + } + }, + ) { + ArticleEmbedBlockContent(block = block) + } +} + +@Composable +private fun ArticleEmbedBlockContent(block: UiArticleBlock.Embed) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + block.imageUrl?.let { + NetworkImage( + model = it, + contentDescription = block.title, + modifier = + Modifier + .size(64.dp) + .clip(FluentTheme.shapes.control), + ) + } ?: FAIcon( + FontAwesomeIcons.Solid.Globe, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = block.title ?: block.url ?: block.htmlFallback.orEmpty(), + style = FluentTheme.typography.body, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + block.description?.let { + Text( + text = it, + style = FluentTheme.typography.caption, + color = FluentTheme.colors.text.text.secondary, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + block.url?.let { + Text( + text = it, + style = FluentTheme.typography.caption, + color = FluentTheme.colors.text.accent.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun ArticleContentGateBlock( + block: UiArticleBlock.ContentGate, + onOpenUrl: (String) -> Unit, +) { + val description = + when (val reason = block.reason) { + is UiArticleContentGateReason.SubscriptionRequired -> { + reason.feeRequired?.let { + stringResource(Res.string.article_content_gate_subscription_description_with_fee, it) + } ?: stringResource(Res.string.article_content_gate_subscription_description) + } + } + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Lock, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = FluentTheme.colors.text.accent.primary, + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(Res.string.article_content_gate_subscription_title), + style = FluentTheme.typography.bodyStrong, + ) + Text( + text = description, + style = FluentTheme.typography.body, + color = FluentTheme.colors.text.text.secondary, + ) + block.actionUrl?.takeIf { it.isNotBlank() }?.let { url -> + AccentButton( + onClick = { + onOpenUrl(url) + }, + ) { + Text(text = stringResource(Res.string.rss_detail_open_in_browser)) + } + } + } + } + } +} + +private fun LazyListScope.articleLoadingItems() { + item(key = "loading-header") { + ArticleBodyContainer { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(36.dp) + .placeholder(true), + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = + Modifier + .size(44.dp) + .clip(FluentTheme.shapes.control) + .placeholder(true), + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = + Modifier + .fillMaxWidth(0.5f) + .height(14.dp) + .placeholder(true), + ) + Box( + modifier = + Modifier + .fillMaxWidth(0.35f) + .height(12.dp) + .placeholder(true), + ) + } + } + } + } + } + item(key = "loading-divider") { + ArticleBodyContainer { + ListItemSeparator(Modifier.fillMaxWidth()) + } + } + items(5) { index -> + ArticleBodyContainer { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + repeat(if (index == 0) 4 else 3) { + Box( + modifier = + Modifier + .fillMaxWidth(if (it == 2) 0.8f else 1f) + .height(16.dp) + .placeholder(true), + ) + } + } + } + } +} + +@Composable +private fun ArticleBodyContainer(content: @Composable () -> Unit) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.TopCenter, + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .widthIn(max = 600.dp) + .padding(horizontal = screenHorizontalPadding), + contentAlignment = Alignment.TopStart, + ) { + content() + } + } +} + +private fun saveArticleFile( + block: UiArticleBlock.File, + window: ComposeWindow?, + scope: CoroutineScope, + downloadManager: DesktopDownloadManager, +) { + FileDialog(window).apply { + mode = FileDialog.SAVE + file = block.downloadFileName() + isVisible = true + val selectedDirectory = directory + val selectedFile = file + if (!selectedDirectory.isNullOrBlank() && !selectedFile.isNullOrBlank()) { + scope.launch { + downloadManager.download( + url = block.url, + targetFile = File(selectedDirectory, selectedFile), + customHeaders = block.customHeaders, + ) + } + } + } +} + +private fun UiArticleBlock.File.downloadFileName(): String { + val sourceName = + name.trim().takeIf { it.isNotBlank() } + ?: url + .substringBefore("?") + .substringBefore("#") + .substringAfterLast("/") + .trim() + .takeIf { it.isNotBlank() } + ?: "file" + val extension = extension?.trim()?.trimStart('.')?.takeIf { it.isNotBlank() } + val fileName = + if (extension != null && !sourceName.hasFileExtension()) { + "$sourceName.$extension" + } else { + sourceName + } + return fileName.toSafeDownloadFileName() +} + +private fun String.hasFileExtension(): Boolean { + val name = substringAfterLast('/').substringAfterLast('\\') + val lastDotIndex = name.lastIndexOf('.') + return lastDotIndex > 0 && lastDotIndex < name.length - 1 +} + +private fun String.toSafeDownloadFileName(): String { + val safeName = + trim() + .map { char -> + if (char == '/' || char == '\\' || char.code < 32 || char.code == 127) { + '_' + } else { + char + } + }.joinToString(separator = "") + return safeName.ifBlank { "file" } +} + +private fun UiMedia.Image.rawImageRoute(): Route.RawImage = + Route.RawImage( + rawImage = url, + customHeaders = customHeaders, + ) + +private fun shareArticleText(article: UiArticle) { + val url = article.sourceUrl?.takeIf { it.isNotBlank() } ?: return + val text = + if (article.title.isBlank()) { + url + } else { + "${article.title}\n$url" + } + Toolkit.getDefaultToolkit().systemClipboard.setContents(StringSelection(text), null) +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/WebViewLoginScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/WebViewLoginScreen.kt index 8f103e4c4..d59aec660 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/WebViewLoginScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/WebViewLoginScreen.kt @@ -6,14 +6,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import io.github.kdroidfilter.webview.web.WebView import io.github.kdroidfilter.webview.web.rememberWebViewState -import io.ktor.http.Url import kotlinx.coroutines.delay import kotlin.time.Duration.Companion.seconds @Composable internal fun WebViewLoginScreen( url: String, - callback: (String) -> Boolean, + callback: (String?) -> Boolean, onBack: () -> Unit, ) { val state = @@ -21,16 +20,18 @@ internal fun WebViewLoginScreen( desktopWebSettings.incognito = true } LaunchedEffect(Unit) { - val urlData = Url(url) - val actualUrl = - urlData.protocol.name - .plus("://") - .plus(urlData.host.removePrefix("m.")) - .plus("/") while (true) { delay(2.seconds) - val cookies = state.webView?.nativeWebView?.getCookiesForUrl(actualUrl) ?: continue - if (callback.invoke(cookies.joinToString("; ") { "${it.name}=${it.value}" })) { + val webView = state.webView?.nativeWebView ?: continue + val cookies = + listOfNotNull(state.lastLoadedUrl, url) + .distinct() + .flatMap { webView.getCookiesForUrl(it) } + .plus(webView.getCookies()) + .distinctBy { listOf(it.domain, it.path, it.name) } + .joinToString("; ") { "${it.name}=${it.value}" } + .takeIf { it.isNotBlank() } + if (callback.invoke(cookies)) { onBack.invoke() break } From c29471a60c099ace22cead4b5ae96d8035640690 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sun, 21 Jun 2026 01:15:49 +0900 Subject: [PATCH 5/9] add article screen for ios and desktop --- .../ui/screen/media/StatusMediaScreen.kt | 136 +- iosApp/flare/Localizable.xcstrings | 7452 +++++++++-------- .../Component/GalleryTimelinePagingView.swift | 4 +- .../UI/Component/Status/FeedUIView.swift | 26 +- .../flare/UI/Component/Status/FeedView.swift | 42 +- iosApp/flare/UI/Route/Route.swift | 19 +- iosApp/flare/UI/Route/Router.swift | 2 +- iosApp/flare/UI/Screen/ArticleScreen.swift | 569 ++ .../flare/UI/Screen/GalleryDetailScreen.swift | 7 +- iosApp/flare/UI/Screen/MediaScreen.swift | 127 +- .../flare/UI/Screen/MediaViewerScreen.swift | 481 ++ .../flare/UI/Screen/StatusMediaScreen.swift | 347 +- .../flare/data/model/tab/Timeline.kt | 13 +- .../home/TimelinePresenterBindingTest.kt | 108 +- 14 files changed, 5161 insertions(+), 4172 deletions(-) create mode 100644 iosApp/flare/UI/Screen/ArticleScreen.swift create mode 100644 iosApp/flare/UI/Screen/MediaViewerScreen.swift diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt index 4855d8e8d..37eda1f63 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt @@ -160,6 +160,7 @@ import moe.tlaster.swiper.Swiper import moe.tlaster.swiper.rememberSwiperState import org.koin.compose.koinInject import java.io.File +import kotlin.math.roundToInt import kotlin.time.Duration.Companion.milliseconds @OptIn( @@ -606,34 +607,61 @@ internal fun MediaViewerScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { if (state.showUi && !state.isLandscapeViewing && pagerState.pageCount > 1) { - Row( - modifier = - Modifier.let { - if (isBigScreen) { - it - } else { - it.padding(top = 8.dp) + if (status == null && pagerState.pageCount > 10) { + MediaPageSlider( + pageCount = pagerState.pageCount, + currentPage = pagerState.currentPage, + onPageSelected = { page -> + scope.launch { + if (pagerState.currentPage != page) { + pagerState.scrollToPage(page) + } } }, - horizontalArrangement = Arrangement.Center, - ) { - repeat(pagerState.pageCount) { iteration -> - val color = - if (pagerState.currentPage == iteration) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onBackground.copy( - alpha = 0.5f, - ) - } - Box( - modifier = - Modifier - .padding(2.dp) - .clip(CircleShape) - .background(color) - .size(8.dp), - ) + modifier = + Modifier + .let { + if (isBigScreen) { + it + } else { + it.padding( + start = 16.dp, + top = 8.dp, + end = 16.dp, + ) + } + }.widthIn(max = 480.dp), + ) + } else { + Row( + modifier = + Modifier.let { + if (isBigScreen) { + it + } else { + it.padding(top = 8.dp) + } + }, + horizontalArrangement = Arrangement.Center, + ) { + repeat(pagerState.pageCount) { iteration -> + val color = + if (pagerState.currentPage == iteration) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground.copy( + alpha = 0.5f, + ) + } + Box( + modifier = + Modifier + .padding(2.dp) + .clip(CircleShape) + .background(color) + .size(8.dp), + ) + } } } } @@ -840,6 +868,62 @@ internal fun MediaViewerScreen( } } +@Composable +private fun MediaPageSlider( + pageCount: Int, + currentPage: Int, + onPageSelected: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val maxPage = (pageCount - 1).coerceAtLeast(0) + var isDragging by remember { mutableStateOf(false) } + var sliderValue by remember(pageCount) { + mutableFloatStateOf(currentPage.coerceIn(0, maxPage).toFloat()) + } + LaunchedEffect(currentPage, maxPage, isDragging) { + if (!isDragging) { + sliderValue = currentPage.coerceIn(0, maxPage).toFloat() + } + } + + val sliderPage = sliderValue.roundToInt().coerceIn(0, maxPage) + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = (sliderPage + 1).toString(), + style = MaterialTheme.typography.labelMedium, + ) + Slider( + value = sliderValue.coerceIn(0f, maxPage.toFloat()), + onValueChange = { value -> + val page = value.roundToInt().coerceIn(0, maxPage) + isDragging = true + sliderValue = page.toFloat() + if (page != currentPage) { + onPageSelected(page) + } + }, + onValueChangeFinished = { + val page = sliderValue.roundToInt().coerceIn(0, maxPage) + isDragging = false + if (page != currentPage) { + onPageSelected(page) + } + }, + valueRange = 0f..maxPage.toFloat(), + steps = (pageCount - 2).coerceAtLeast(0), + modifier = Modifier.weight(1f), + ) + Text( + text = pageCount.toString(), + style = MaterialTheme.typography.labelMedium, + ) + } +} + @androidx.annotation.OptIn(UnstableApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable diff --git a/iosApp/flare/Localizable.xcstrings b/iosApp/flare/Localizable.xcstrings index 3c2ac9101..d34d38e90 100644 --- a/iosApp/flare/Localizable.xcstrings +++ b/iosApp/flare/Localizable.xcstrings @@ -22053,6 +22053,196 @@ } } }, + "Block user" : { + "localizations" : { + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blokkeer" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "حظر" + } + }, + "bg" : { + "stringUnit" : { + "state" : "translated", + "value" : "Блокиране" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bloquejar" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blokovat" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bloker" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blockieren" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Αποκλεισμός" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Block user" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bloquear" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bloquer" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "חסימה" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiltás" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blocca" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ユーザーをブロック" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 차단" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blokker" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blokkeren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zablokuj" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bloquear" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bloquear" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blochează" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заблокировать" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blokiraj" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blockera" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Engelle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заблокувати" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chặn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "屏蔽用户" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "封鎖使用者" + } + } + } + }, "block_user_alert_message" : { "localizations" : { "af" : { @@ -28141,6 +28331,196 @@ } } }, + "Bookmark" : { + "localizations" : { + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voeg boekmerk by" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إضافة إشارة مرجعية" + } + }, + "bg" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добави отметка" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afegir marcador" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Přidat do záložek" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilføj bogmærke" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lesezeichen hinzufügen" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Προσθήκη σελιδοδείκτη" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bookmark" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Añadir marcador" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lisää kirjanmerkki" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter un signet" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הוסף סימנייה" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Könyvjelző hozzáadása" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiungi segnalibro" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ブックマーク" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "북마크" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legg til bokmerke" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bladwijzer toevoegen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodaj zakładkę" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar marcador" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salvar favorito" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adaugă semn de carte" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить в закладки" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodaj obeleživač" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lägg till bokmärke" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yer işareti ekle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "У закладки" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thêm dấu trang" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "书签" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "書籤" + } + } + } + }, "bookmark_add" : { "localizations" : { "af" : { @@ -28521,6 +28901,196 @@ } } }, + "Button row" : { + "localizations" : { + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "Knoppiery" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "صف الأزرار" + } + }, + "bg" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ред с бутони" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fila de botons" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Řádek tlačítek" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Knaprække" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schaltflächenzeile" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Σειρά κουμπιών" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Button row" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fila de botones" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Painikerivi" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rangée de boutons" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שורת כפתורים" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gombsor" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Riga pulsanti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ボタン行" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "버튼 행" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Knapperad" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Knoppenrij" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wiersz przycisków" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Linha de botões" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Linha de botões" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rând de butoane" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Строка кнопок" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ред дугмади" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Knapprad" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Düğme satırı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рядок кнопок" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hàng nút" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "按钮行" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "按鈕列" + } + } + } + }, "Cancel" : { "localizations" : { "af" : { @@ -29738,22 +30308,22 @@ "value" : "Choisir l’ordre et la visibilité des boutons d’action du post" } }, - "hu" : { + "he" : { "stringUnit" : { "state" : "translated", - "value" : "Válaszd ki a bejegyzésművelet-gombok sorrendjét és láthatóságát" + "value" : "בחר את הסדר והנראות של כפתורי הפעולה בפוסט" } }, - "it" : { + "hu" : { "stringUnit" : { "state" : "translated", - "value" : "Scegli ordine e visibilità dei pulsanti di azione del post" + "value" : "Válaszd ki a bejegyzésművelet-gombok sorrendjét és láthatóságát" } }, - "he" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "בחר את הסדר והנראות של כפתורי הפעולה בפוסט" + "value" : "Scegli ordine e visibilità dei pulsanti di azione del post" } }, "ja" : { @@ -29768,16 +30338,16 @@ "value" : "게시물 동작 버튼의 순서와 표시 여부 선택" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Kies de volgorde en zichtbaarheid van actieknoppen voor berichten" + "value" : "Velg rekkefølge og synlighet for handlingsknapper på innlegg" } }, - "nb" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Velg rekkefølge og synlighet for handlingsknapper på innlegg" + "value" : "Kies de volgorde en zichtbaarheid van actieknoppen voor berichten" } }, "pl" : { @@ -29786,16 +30356,16 @@ "value" : "Wybierz kolejność i widoczność przycisków akcji wpisu" } }, - "pt-BR" : { + "pt" : { "stringUnit" : { "state" : "translated", - "value" : "Escolha a ordem e a visibilidade dos botões de ação da postagem" + "value" : "Escolha a ordem e a visibilidade dos botões de ação da publicação" } }, - "pt" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Escolha a ordem e a visibilidade dos botões de ação da publicação" + "value" : "Escolha a ordem e a visibilidade dos botões de ação da postagem" } }, "ro" : { @@ -29930,22 +30500,22 @@ "value" : "Choisir les actions affichées dans la rangée, le menu Plus ou masquées" } }, - "hu" : { + "he" : { "stringUnit" : { "state" : "translated", - "value" : "Válaszd ki, mely műveletek jelenjenek meg a sorban, a Továbbiak menüben vagy maradjanak rejtve" + "value" : "בחר אילו פעולות יופיעו בשורה, בתפריט עוד או יישארו מוסתרות" } }, - "it" : { + "hu" : { "stringUnit" : { "state" : "translated", - "value" : "Scegli quali azioni appaiono nella riga, nel menu Altro o restano nascoste" + "value" : "Válaszd ki, mely műveletek jelenjenek meg a sorban, a Továbbiak menüben vagy maradjanak rejtve" } }, - "he" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "בחר אילו פעולות יופיעו בשורה, בתפריט עוד או יישארו מוסתרות" + "value" : "Scegli quali azioni appaiono nella riga, nel menu Altro o restano nascoste" } }, "ja" : { @@ -29960,16 +30530,16 @@ "value" : "버튼 행, 더 보기 메뉴 또는 숨김에 표시할 동작 선택" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Kies welke acties in de rij, het Meer-menu of verborgen blijven" + "value" : "Velg hvilke handlinger som vises i raden, Mer-menyen eller holdes skjult" } }, - "nb" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Velg hvilke handlinger som vises i raden, Mer-menyen eller holdes skjult" + "value" : "Kies welke acties in de rij, het Meer-menu of verborgen blijven" } }, "pl" : { @@ -29978,16 +30548,16 @@ "value" : "Wybierz, które akcje pojawią się w wierszu, menu Więcej albo pozostaną ukryte" } }, - "pt-BR" : { + "pt" : { "stringUnit" : { "state" : "translated", - "value" : "Escolha quais ações aparecem na linha, no menu Mais ou ficam ocultas" + "value" : "Escolha que ações aparecem na linha, no menu Mais ou ficam ocultas" } }, - "pt" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Escolha que ações aparecem na linha, no menu Mais ou ficam ocultas" + "value" : "Escolha quais ações aparecem na linha, no menu Mais ou ficam ocultas" } }, "ro" : { @@ -30806,6 +31376,196 @@ } } }, + "Comment" : { + "localizations" : { + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kommentaar" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تعليق" + } + }, + "bg" : { + "stringUnit" : { + "state" : "translated", + "value" : "Коментар" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comenta" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Komentovat" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kommentar" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kommentieren" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Σχόλιο" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comment" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comentario" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kommentti" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Commenter" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "תגובה" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hozzászólás" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Commenta" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "コメント" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "댓글" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kommentar" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reactie" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Komentarz" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comentar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comentar" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comentariu" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Комментировать" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Коментариши" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kommentera" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yorum yap" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Коментувати" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bình luận" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "评论" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "留言" + } + } + } + }, "Comments" : { "comment" : "The title of the \"Comments\" tab in the gallery.", "isCommentAutoGenerated" : true @@ -33356,22 +34116,22 @@ "value" : "Personnaliser les actions" } }, - "hu" : { + "he" : { "stringUnit" : { "state" : "translated", - "value" : "Műveletek testreszabása" + "value" : "התאמה אישית של פעולות" } }, - "it" : { + "hu" : { "stringUnit" : { "state" : "translated", - "value" : "Personalizza azioni" + "value" : "Műveletek testreszabása" } }, - "he" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "התאמה אישית של פעולות" + "value" : "Personalizza azioni" } }, "ja" : { @@ -33386,16 +34146,16 @@ "value" : "동작 사용자화" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Acties aanpassen" + "value" : "Tilpass handlinger" } }, - "nb" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tilpass handlinger" + "value" : "Acties aanpassen" } }, "pl" : { @@ -33404,13 +34164,13 @@ "value" : "Dostosuj akcje" } }, - "pt-BR" : { + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Personalizar ações" } }, - "pt" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Personalizar ações" @@ -41835,6 +42595,10 @@ } } }, + "Failed to load article" : { + "comment" : "A title for a view that displays an error message when loading an article.", + "isCommentAutoGenerated" : true + }, "Failed to load models" : { "extractionState" : "stale", "localizations" : { @@ -42026,6 +42790,222 @@ } } }, + "fanbox_recommended_creators_title" : { + "comment" : "Title of a section of the Fanbox app that shows recommended creators.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Recommended creators" + } + } + } + }, + "fanbox_supported_title" : { + "comment" : "Title of a section of the fanbox that shows only posts that the user has supported.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Supported posts" + } + } + } + }, + "Favorite" : { + "localizations" : { + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gunsteling" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تفضيل" + } + }, + "bg" : { + "stringUnit" : { + "state" : "translated", + "value" : "Любими" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preferit" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oblíbené" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorit" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorisieren" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Αγαπημένο" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorite" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorito" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suosikki" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favori" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מועדף" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kedvenc" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preferito" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お気に入り" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "즐겨찾기" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favoritt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favoriet" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ulubione" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorito" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favoritar" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorit" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В избранное" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Omiljeno" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favoritmarkera" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorilere ekle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "У вибране" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yêu thích" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "收藏" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "收藏" + } + } + } + }, "Favourite" : { "localizations" : { "af" : { @@ -42407,6 +43387,196 @@ } } }, + "Fx share" : { + "localizations" : { + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deel via FxEmbed" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مشاركة عبر FxEmbed" + } + }, + "bg" : { + "stringUnit" : { + "state" : "translated", + "value" : "Споделяне чрез FxEmbed" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compartir via FxEmbed" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sdílet přes FxEmbed" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Del via FxEmbed" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Über FxEmbed teilen" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Κοινοποίηση μέσω FxEmbed" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fx share" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compartir vía FxEmbed" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jaa FxEmbedin kautta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Partager via FxEmbed" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שתף דרך FxEmbed" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Megosztás FxEmbed-en keresztül" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Condividi tramite FxEmbed" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fx共有" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fx 공유" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Del via FxEmbed" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delen via FxEmbed" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Udostępnij przez FxEmbed" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Partilhar via FxEmbed" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compartilhar via FxEmbed" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Partajează prin FxEmbed" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поделиться через FxEmbed" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podeli putem FxEmbed" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dela via FxEmbed" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "FxEmbed ile paylaş" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поділитися через FxEmbed" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chia sẻ qua FxEmbed" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fx 分享" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fx 分享" + } + } + } + }, "fx_share" : { "localizations" : { "af" : { @@ -43167,6 +44337,386 @@ } } }, + "Hidden" : { + "localizations" : { + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versteek" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مخفي" + } + }, + "bg" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрити" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amagat" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skryté" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skjult" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausgeblendet" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Κρυφό" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hidden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oculto" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Piilotettu" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Masqué" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מוסתר" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rejtett" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nascosto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "非表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "숨김" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skjult" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verborgen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukryte" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oculto" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oculto" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ascuns" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыто" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скривено" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dold" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gizli" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Приховано" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã ẩn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐藏" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "隱藏" + } + } + } + }, + "Hide action" : { + "localizations" : { + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versteek aksie" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إخفاء الإجراء" + } + }, + "bg" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скриване на действието" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amaga l’acció" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skrýt akci" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skjul handling" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktion ausblenden" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Απόκρυψη ενέργειας" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hide action" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocultar acción" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Piilota toiminto" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Masquer l’action" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הסתר פעולה" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Művelet elrejtése" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nascondi azione" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アクションを非表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "동작 숨기기" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skjul handling" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actie verbergen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukryj akcję" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocultar ação" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocultar ação" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ascunde acțiunea" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть действие" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сакриј радњу" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dölj åtgärd" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eylemi gizle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Приховати дію" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ẩn hành động" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐藏操作" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "隱藏操作" + } + } + } + }, "High" : { "localizations" : { "af" : { @@ -47355,6 +48905,196 @@ } } }, + "Like" : { + "localizations" : { + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hou van" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إعجاب" + } + }, + "bg" : { + "stringUnit" : { + "state" : "translated", + "value" : "Харесване" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "M'agrada" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "To se mi líbí" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synes godt om" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gefällt mir" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Μου αρέσει" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Like" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Me gusta" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tykkää" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "J'aime" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אהבתי" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kedvelés" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mi piace" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "いいね" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "좋아요" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lik" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vind ik leuk" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lubię to!" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gostar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Curtir" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apreciază" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Лайк" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sviđa mi se" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gilla" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beğen" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подобається" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thích" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "赞" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "喜歡" + } + } + } + }, "liked_tab_title" : { "localizations" : { "af" : { @@ -99598,6 +101338,196 @@ } } }, + "More menu" : { + "localizations" : { + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meer-kieslys" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "قائمة المزيد" + } + }, + "bg" : { + "stringUnit" : { + "state" : "translated", + "value" : "Меню „Още“" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menú Més" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nabídka Další" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menuen Mere" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mehr-Menü" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Μενού Περισσότερα" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "More menu" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menú Más" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lisää-valikko" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menu Plus" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "תפריט עוד" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Továbbiak menü" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menu Altro" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "その他メニュー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "더 보기 메뉴" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mer-meny" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meer-menu" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menu Więcej" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menu Mais" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menu Mais" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meniu Mai multe" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Меню Ещё" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Мени Још" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menyn Mer" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Daha Fazla menüsü" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Меню Ще" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menu Thêm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更多菜单" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "更多選單" + } + } + } + }, "Move down" : { "comment" : "A button that moves a post action family down in the list.", "isCommentAutoGenerated" : true, @@ -99674,22 +101604,22 @@ "value" : "Descendre" } }, - "hu" : { + "he" : { "stringUnit" : { "state" : "translated", - "value" : "Mozgatás le" + "value" : "העבר למטה" } }, - "it" : { + "hu" : { "stringUnit" : { "state" : "translated", - "value" : "Sposta giù" + "value" : "Mozgatás le" } }, - "he" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "העבר למטה" + "value" : "Sposta giù" } }, "ja" : { @@ -99704,16 +101634,16 @@ "value" : "아래로 이동" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Omlaag verplaatsen" + "value" : "Flytt ned" } }, - "nb" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Flytt ned" + "value" : "Omlaag verplaatsen" } }, "pl" : { @@ -99722,13 +101652,13 @@ "value" : "Przenieś w dół" } }, - "pt-BR" : { + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Mover para baixo" } }, - "pt" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Mover para baixo" @@ -99790,6 +101720,386 @@ } } }, + "Move to button row" : { + "localizations" : { + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skuif na knoppiery" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "نقل إلى صف الأزرار" + } + }, + "bg" : { + "stringUnit" : { + "state" : "translated", + "value" : "Преместване в реда с бутони" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mou a la fila de botons" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Přesunout do řádku tlačítek" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Flyt til knaprække" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "In die Schaltflächenzeile verschieben" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Μετακίνηση στη σειρά κουμπιών" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Move to button row" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mover a la fila de botones" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Siirrä painikeriville" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Déplacer vers la rangée de boutons" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "העבר לשורת הכפתורים" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Áthelyezés a gombsorba" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sposta nella riga pulsanti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ボタン行へ移動" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "버튼 행으로 이동" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Flytt til knapperad" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Naar knoppenrij verplaatsen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przenieś do wiersza przycisków" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mover para a linha de botões" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mover para a linha de botões" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mută în rândul de butoane" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переместить в строку кнопок" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Премести у ред дугмади" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Flytta till knapprad" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Düğme satırına taşı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перемістити до рядка кнопок" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chuyển vào hàng nút" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移动到按钮行" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "移至按鈕列" + } + } + } + }, + "Move to More menu" : { + "localizations" : { + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skuif na Meer-kieslys" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "نقل إلى قائمة المزيد" + } + }, + "bg" : { + "stringUnit" : { + "state" : "translated", + "value" : "Преместване в менюто „Още“" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mou al menú Més" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Přesunout do nabídky Další" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Flyt til menuen Mere" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ins Mehr-Menü verschieben" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Μετακίνηση στο μενού Περισσότερα" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Move to More menu" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mover al menú Más" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Siirrä Lisää-valikkoon" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Déplacer vers le menu Plus" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "העבר לתפריט עוד" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Áthelyezés a Továbbiak menübe" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sposta nel menu Altro" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "その他メニューへ移動" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "더 보기 메뉴로 이동" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Flytt til Mer-meny" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Naar Meer-menu verplaatsen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przenieś do menu Więcej" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mover para o menu Mais" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mover para o menu Mais" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mută în meniul Mai multe" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переместить в меню Ещё" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Премести у мени Још" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Flytta till menyn Mer" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Daha Fazla menüsüne taşı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перемістити до меню Ще" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chuyển vào menu Thêm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移动到更多菜单" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "移至更多選單" + } + } + } + }, "Move up" : { "comment" : "A button that moves a post action up in the list.", "isCommentAutoGenerated" : true, @@ -99866,22 +102176,22 @@ "value" : "Monter" } }, - "hu" : { + "he" : { "stringUnit" : { "state" : "translated", - "value" : "Mozgatás fel" + "value" : "העבר למעלה" } }, - "it" : { + "hu" : { "stringUnit" : { "state" : "translated", - "value" : "Sposta su" + "value" : "Mozgatás fel" } }, - "he" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "העבר למעלה" + "value" : "Sposta su" } }, "ja" : { @@ -99896,16 +102206,16 @@ "value" : "위로 이동" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Omhoog verplaatsen" + "value" : "Flytt opp" } }, - "nb" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Flytt opp" + "value" : "Omhoog verplaatsen" } }, "pl" : { @@ -99914,13 +102224,13 @@ "value" : "Przenieś w górę" } }, - "pt-BR" : { + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Mover para cima" } }, - "pt" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Mover para cima" @@ -100172,6 +102482,196 @@ } } }, + "Mute user" : { + "localizations" : { + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "Demp" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "كتم" + } + }, + "bg" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заглушаване" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Silenciar" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skrýt" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lydløs" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stummschalten" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Σίγαση" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mute user" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Silenciar" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mykistä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Masquer" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "השתקה" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Némítás" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Silenzia" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ユーザーをミュート" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 뮤트" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Demp" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dempen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wycisz" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Silenciar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Silenciar" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ignoră" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Игнорировать" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utišaj" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tysta" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sessize al" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Приглушити" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ẩn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "静音用户" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "靜音使用者" + } + } + } + }, "mute_user_alert_message" : { "localizations" : { "af" : { @@ -101009,22 +103509,22 @@ "value" : "Aucune action" } }, - "hu" : { + "he" : { "stringUnit" : { "state" : "translated", - "value" : "Nincsenek műveletek" + "value" : "אין פעולות" } }, - "it" : { + "hu" : { "stringUnit" : { "state" : "translated", - "value" : "Nessuna azione" + "value" : "Nincsenek műveletek" } }, - "he" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "אין פעולות" + "value" : "Nessuna azione" } }, "ja" : { @@ -101039,16 +103539,16 @@ "value" : "동작 없음" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Geen acties" + "value" : "Ingen handlinger" } }, - "nb" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ingen handlinger" + "value" : "Geen acties" } }, "pl" : { @@ -101057,16 +103557,16 @@ "value" : "Brak akcji" } }, - "pt-BR" : { + "pt" : { "stringUnit" : { "state" : "translated", - "value" : "Nenhuma ação" + "value" : "Sem ações" } }, - "pt" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Sem ações" + "value" : "Nenhuma ação" } }, "ro" : { @@ -109480,22 +111980,22 @@ "value" : "Actions du post" } }, - "hu" : { + "he" : { "stringUnit" : { "state" : "translated", - "value" : "Bejegyzésműveletek" + "value" : "פעולות פוסט" } }, - "it" : { + "hu" : { "stringUnit" : { "state" : "translated", - "value" : "Azioni del post" + "value" : "Bejegyzésműveletek" } }, - "he" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "פעולות פוסט" + "value" : "Azioni del post" } }, "ja" : { @@ -109510,16 +112010,16 @@ "value" : "게시물 동작" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Berichtacties" + "value" : "Innleggshandlinger" } }, - "nb" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innleggshandlinger" + "value" : "Berichtacties" } }, "pl" : { @@ -109528,16 +112028,16 @@ "value" : "Akcje wpisu" } }, - "pt-BR" : { + "pt" : { "stringUnit" : { "state" : "translated", - "value" : "Ações da postagem" + "value" : "Ações da publicação" } }, - "pt" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Ações da publicação" + "value" : "Ações da postagem" } }, "ro" : { @@ -111380,7 +113880,197 @@ } } }, - "reaction_add" : { + "Quote" : { + "localizations" : { + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "Haal aan" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "اقتباس" + } + }, + "bg" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цитат" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Citar" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Citovat" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Citér" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zitieren" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Παράθεση" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quote" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Citar" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lainaa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Citer" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ציטוט" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Idézés" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cita" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "引用" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "인용" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Siter" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Citeren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cytat" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Citar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Citar" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Citat" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цитировать" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Citiraj" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Citera" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alıntıla" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цитата" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trích dẫn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "引用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "引用" + } + } + } + }, + "React" : { "localizations" : { "af" : { "stringUnit" : { @@ -111391,13 +114081,13 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "إضافة رد فعل" + "value" : "إضافة تفاعل" } }, "bg" : { "stringUnit" : { "state" : "translated", - "value" : "Добавяне на реакция" + "value" : "Добави реакция" } }, "ca" : { @@ -111433,7 +114123,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Add reaction" + "value" : "React" } }, "es" : { @@ -111457,7 +114147,7 @@ "he" : { "stringUnit" : { "state" : "translated", - "value" : "הוספת תגובה" + "value" : "הוסף תגובה" } }, "hu" : { @@ -111475,13 +114165,203 @@ "ja" : { "stringUnit" : { "state" : "translated", - "value" : "リアクションを追加" + "value" : "リアクション" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "반응 추가" + "value" : "반응" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legg til reaksjon" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reactie toevoegen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodaj reakcję" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar reação" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar reação" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adaugă reacție" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить реакцию" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodaj reakciju" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lägg till reaktion" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tepki ekle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Додати реакцію" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thêm cảm xúc" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "回应" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "反應" + } + } + } + }, + "reaction_add" : { + "localizations" : { + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voeg reaksie by" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إضافة رد فعل" + } + }, + "bg" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавяне на реакция" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afegir reacció" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Přidat reakci" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilføj reaktion" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reaktion hinzufügen" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Προσθήκη αντίδρασης" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add reaction" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Añadir reacción" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lisää reaktio" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter une réaction" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הוספת תגובה" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reakció hozzáadása" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiungi reazione" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リアクションを追加" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "반응 추가" } }, "nb" : { @@ -113478,6 +116358,196 @@ } } }, + "Reply" : { + "localizations" : { + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "Antwoord" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "رد" + } + }, + "bg" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отговор" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Respondre" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odpovědět" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Svar" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Antworten" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Απάντηση" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reply" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Responder" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vastaa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Répondre" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "תגובה" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Válasz" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rispondi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "返信" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "답글" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Svar" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beantwoorden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odpowiedz" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Responder" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Responder" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Răspunde" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ответить" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odgovori" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Svara" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yanıtla" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Відповісти" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trả lời" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "回复" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "回覆" + } + } + } + }, "Reply to %@" : { "localizations" : { "af" : { @@ -113858,6 +116928,386 @@ } } }, + "Report" : { + "localizations" : { + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rapporteer" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إبلاغ" + } + }, + "bg" : { + "stringUnit" : { + "state" : "translated", + "value" : "Докладване" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Informar" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nahlásit" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anmeld" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Melden" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Αναφορά" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Report" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reportar" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Raportoi" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Signaler" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "דיווח" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jelentés" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Segnala" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "報告" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "신고" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rapporter" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rapporteren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zgłoś" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Denunciar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Denunciar" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Raportează" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пожаловаться" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prijavi" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rapportera" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bildir" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скаржитися" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Báo cáo" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "举报" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "檢舉" + } + } + } + }, + "Repost" : { + "localizations" : { + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "Herplaas" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إعادة نشر" + } + }, + "bg" : { + "stringUnit" : { + "state" : "translated", + "value" : "Препубликуване" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Republicar" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Přesdílet" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Post igen" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Teilen" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Αναδημοσίευση" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repost" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compartir" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jaa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repartager" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פרסום מחדש" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Újraközlés" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ripubblica" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リポスト" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리포스트" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reposter" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podaj dalej" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Republicar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repostar" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repostare" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Репост" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ponovo objavi" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reposta" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yeniden paylaş" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поширити" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đăng lại" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "转发" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "轉發" + } + } + } + }, "Retry" : { "localizations" : { "af" : { @@ -127163,6 +130613,196 @@ } } }, + "Share" : { + "localizations" : { + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deel" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مشاركة" + } + }, + "bg" : { + "stringUnit" : { + "state" : "translated", + "value" : "Споделяне" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compartir" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sdílet" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Del" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Teilen" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Κοινοποίηση" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compartir" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jaa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Partager" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שיתוף" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Megosztás" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Condividi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "共有" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공유" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Del" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Udostępnij" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Partilhar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compartilhar" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Partajează" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поделиться" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podeli" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dela" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paylaş" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поділитися" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chia sẻ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分享" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "分享" + } + } + } + }, "Share image" : { "comment" : "A title for the share sheet.", "localizations" : { @@ -139137,6 +142777,10 @@ } } }, + "Subscription required" : { + "comment" : "A title for a gated article.", + "isCommentAutoGenerated" : true + }, "subscription_url_hint" : { "localizations" : { "af" : { @@ -151501,3616 +155145,6 @@ } } } - }, - "Button row" : { - "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Knoppiery" - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "صف الأزرار" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ред с бутони" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fila de botons" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Řádek tlačítek" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Knaprække" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Schaltflächenzeile" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Σειρά κουμπιών" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Button row" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fila de botones" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Painikerivi" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rangée de boutons" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gombsor" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Riga pulsanti" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "שורת כפתורים" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ボタン行" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "버튼 행" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Knoppenrij" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Knapperad" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wiersz przycisków" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Linha de botões" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Linha de botões" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rând de butoane" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Строка кнопок" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ред дугмади" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Knapprad" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Düğme satırı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Рядок кнопок" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hàng nút" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "按钮行" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "按鈕列" - } - } - } - }, - "More menu" : { - "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Meer-kieslys" - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "قائمة المزيد" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Меню „Още“" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Menú Més" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nabídka Další" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Menuen Mere" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mehr-Menü" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Μενού Περισσότερα" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "More menu" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Menú Más" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lisää-valikko" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Menu Plus" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Továbbiak menü" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Menu Altro" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "תפריט עוד" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "その他メニュー" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "더 보기 메뉴" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Meer-menu" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mer-meny" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Menu Więcej" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Menu Mais" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Menu Mais" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Meniu Mai multe" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Меню Ещё" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Мени Још" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Menyn Mer" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Daha Fazla menüsü" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Меню Ще" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Menu Thêm" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "更多菜单" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "更多選單" - } - } - } - }, - "Hidden" : { - "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Versteek" - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "مخفي" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Скрити" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Amagat" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Skryté" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Skjult" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ausgeblendet" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Κρυφό" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hidden" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Oculto" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Piilotettu" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Masqué" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rejtett" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nascosto" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מוסתר" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "非表示" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "숨김" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verborgen" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Skjult" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ukryte" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Oculto" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Oculto" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ascuns" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Скрыто" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Скривено" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dold" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gizli" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Приховано" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Đã ẩn" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "隐藏" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "隱藏" - } - } - } - }, - "Move to button row" : { - "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Skuif na knoppiery" - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "نقل إلى صف الأزرار" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Преместване в реда с бутони" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mou a la fila de botons" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Přesunout do řádku tlačítek" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Flyt til knaprække" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "In die Schaltflächenzeile verschieben" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Μετακίνηση στη σειρά κουμπιών" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Move to button row" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mover a la fila de botones" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Siirrä painikeriville" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Déplacer vers la rangée de boutons" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Áthelyezés a gombsorba" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sposta nella riga pulsanti" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "העבר לשורת הכפתורים" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ボタン行へ移動" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "버튼 행으로 이동" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Naar knoppenrij verplaatsen" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Flytt til knapperad" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Przenieś do wiersza przycisków" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mover para a linha de botões" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mover para a linha de botões" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mută în rândul de butoane" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Переместить в строку кнопок" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Премести у ред дугмади" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Flytta till knapprad" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Düğme satırına taşı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Перемістити до рядка кнопок" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chuyển vào hàng nút" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "移动到按钮行" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "移至按鈕列" - } - } - } - }, - "Move to More menu" : { - "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Skuif na Meer-kieslys" - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "نقل إلى قائمة المزيد" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Преместване в менюто „Още“" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mou al menú Més" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Přesunout do nabídky Další" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Flyt til menuen Mere" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ins Mehr-Menü verschieben" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Μετακίνηση στο μενού Περισσότερα" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Move to More menu" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mover al menú Más" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Siirrä Lisää-valikkoon" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Déplacer vers le menu Plus" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Áthelyezés a Továbbiak menübe" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sposta nel menu Altro" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "העבר לתפריט עוד" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "その他メニューへ移動" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "더 보기 메뉴로 이동" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Naar Meer-menu verplaatsen" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Flytt til Mer-meny" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Przenieś do menu Więcej" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mover para o menu Mais" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mover para o menu Mais" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mută în meniul Mai multe" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Переместить в меню Ещё" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Премести у мени Још" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Flytta till menyn Mer" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Daha Fazla menüsüne taşı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Перемістити до меню Ще" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chuyển vào menu Thêm" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "移动到更多菜单" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "移至更多選單" - } - } - } - }, - "Hide action" : { - "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Versteek aksie" - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "إخفاء الإجراء" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Скриване на действието" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Amaga l’acció" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Skrýt akci" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Skjul handling" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aktion ausblenden" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Απόκρυψη ενέργειας" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hide action" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ocultar acción" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Piilota toiminto" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Masquer l’action" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Művelet elrejtése" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nascondi azione" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הסתר פעולה" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "アクションを非表示" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "동작 숨기기" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Actie verbergen" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Skjul handling" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ukryj akcję" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ocultar ação" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ocultar ação" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ascunde acțiunea" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Скрыть действие" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Сакриј радњу" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dölj åtgärd" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Eylemi gizle" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Приховати дію" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ẩn hành động" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "隐藏操作" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "隱藏操作" - } - } - } - }, - "Reply" : { - "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Antwoord" - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "رد" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Отговор" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Respondre" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odpovědět" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Svar" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Antworten" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Απάντηση" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reply" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Responder" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vastaa" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Répondre" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Válasz" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rispondi" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "תגובה" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "返信" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "답글" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Beantwoorden" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Svar" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odpowiedz" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Responder" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Responder" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Răspunde" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ответить" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odgovori" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Svara" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Yanıtla" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Відповісти" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trả lời" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "回复" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "回覆" - } - } - } - }, - "Comment" : { - "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kommentaar" - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تعليق" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Коментар" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Comenta" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Komentovat" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kommentar" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kommentieren" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Σχόλιο" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Comment" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Comentario" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kommentti" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Commenter" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hozzászólás" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Commenta" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "תגובה" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "コメント" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "댓글" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reactie" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kommentar" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Komentarz" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Comentar" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Comentar" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Comentariu" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Комментировать" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Коментариши" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kommentera" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Yorum yap" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Коментувати" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bình luận" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "评论" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "留言" - } - } - } - }, - "Repost" : { - "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Herplaas" - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "إعادة نشر" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Препубликуване" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Republicar" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Přesdílet" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Post igen" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Teilen" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Αναδημοσίευση" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Repost" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Compartir" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jaa" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Repartager" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Újraközlés" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ripubblica" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "פרסום מחדש" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "リポスト" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "리포스트" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Delen" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reposter" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Podaj dalej" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Repostar" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Republicar" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Repostare" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Репост" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ponovo objavi" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reposta" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Yeniden paylaş" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Поширити" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Đăng lại" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "转发" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "轉發" - } - } - } - }, - "Quote" : { - "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Haal aan" - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "اقتباس" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Цитат" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Citar" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Citovat" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Citér" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zitieren" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Παράθεση" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Quote" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Citar" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lainaa" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Citer" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Idézés" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cita" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "ציטוט" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "引用" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "인용" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Citeren" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Siter" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cytat" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Citar" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Citar" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Citat" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Цитировать" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Citiraj" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Citera" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Alıntıla" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Цитата" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trích dẫn" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "引用" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "引用" - } - } - } - }, - "Like" : { - "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hou van" - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "إعجاب" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Харесване" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "M'agrada" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "To se mi líbí" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Synes godt om" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gefällt mir" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Μου αρέσει" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Like" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Me gusta" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tykkää" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "J'aime" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kedvelés" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mi piace" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אהבתי" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "いいね" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "좋아요" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vind ik leuk" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lik" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lubię to!" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Curtir" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gostar" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Apreciază" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Лайк" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sviđa mi se" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gilla" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Beğen" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Подобається" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Thích" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "赞" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "喜歡" - } - } - } - }, - "React" : { - "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Voeg reaksie by" - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "إضافة تفاعل" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Добави реакция" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Afegir reacció" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Přidat reakci" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tilføj reaktion" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reaktion hinzufügen" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Προσθήκη αντίδρασης" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "React" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Añadir reacción" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lisää reaktio" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ajouter une réaction" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reakció hozzáadása" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aggiungi reazione" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הוסף תגובה" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "リアクション" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "반응" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reactie toevoegen" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Legg til reaksjon" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dodaj reakcję" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Adicionar reação" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Adicionar reação" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Adaugă reacție" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Добавить реакцию" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dodaj reakciju" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lägg till reaktion" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tepki ekle" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Додати реакцію" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Thêm cảm xúc" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "回应" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "反應" - } - } - } - }, - "Bookmark" : { - "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Voeg boekmerk by" - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "إضافة إشارة مرجعية" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Добави отметка" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Afegir marcador" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Přidat do záložek" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tilføj bogmærke" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lesezeichen hinzufügen" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Προσθήκη σελιδοδείκτη" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bookmark" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Añadir marcador" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lisää kirjanmerkki" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ajouter un signet" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Könyvjelző hozzáadása" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aggiungi segnalibro" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הוסף סימנייה" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ブックマーク" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "북마크" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bladwijzer toevoegen" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Legg til bokmerke" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dodaj zakładkę" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Salvar favorito" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Adicionar marcador" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Adaugă semn de carte" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Добавить в закладки" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dodaj obeleživač" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lägg till bokmärke" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Yer işareti ekle" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "У закладки" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Thêm dấu trang" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "书签" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "書籤" - } - } - } - }, - "Favorite" : { - "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gunsteling" - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تفضيل" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Любими" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Preferit" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Oblíbené" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Favorit" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Favorisieren" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Αγαπημένο" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Favorite" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Favorito" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Suosikki" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Favori" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kedvenc" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Preferito" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מועדף" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "お気に入り" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "즐겨찾기" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Favoriet" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Favoritt" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ulubione" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Favoritar" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Favorito" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Favorit" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "В избранное" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Omiljeno" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Favoritmarkera" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Favorilere ekle" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "У вибране" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Yêu thích" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "收藏" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "收藏" - } - } - } - }, - "Share" : { - "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Deel" - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "مشاركة" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Споделяне" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Compartir" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sdílet" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Del" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Teilen" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Κοινοποίηση" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Share" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Compartir" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jaa" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Partager" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Megosztás" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Condividi" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "שיתוף" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "共有" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "공유" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Delen" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Del" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Udostępnij" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Compartilhar" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Partilhar" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Partajează" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Поделиться" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Podeli" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dela" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Paylaş" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Поділитися" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chia sẻ" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "分享" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "分享" - } - } - } - }, - "Fx share" : { - "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Deel via FxEmbed" - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "مشاركة عبر FxEmbed" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Споделяне чрез FxEmbed" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Compartir via FxEmbed" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sdílet přes FxEmbed" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Del via FxEmbed" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Über FxEmbed teilen" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Κοινοποίηση μέσω FxEmbed" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fx share" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Compartir vía FxEmbed" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jaa FxEmbedin kautta" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Partager via FxEmbed" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Megosztás FxEmbed-en keresztül" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Condividi tramite FxEmbed" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "שתף דרך FxEmbed" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fx共有" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fx 공유" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Delen via FxEmbed" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Del via FxEmbed" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Udostępnij przez FxEmbed" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Compartilhar via FxEmbed" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Partilhar via FxEmbed" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Partajează prin FxEmbed" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Поделиться через FxEmbed" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Podeli putem FxEmbed" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dela via FxEmbed" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "FxEmbed ile paylaş" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Поділитися через FxEmbed" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chia sẻ qua FxEmbed" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fx 分享" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fx 分享" - } - } - } - }, - "Report" : { - "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rapporteer" - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "إبلاغ" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Докладване" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Informar" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nahlásit" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anmeld" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Melden" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Αναφορά" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Report" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reportar" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Raportoi" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Signaler" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jelentés" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Segnala" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "דיווח" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "報告" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "신고" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rapporteren" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rapporter" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zgłoś" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Denunciar" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Denunciar" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Raportează" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Пожаловаться" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Prijavi" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rapportera" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bildir" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Скаржитися" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Báo cáo" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "举报" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "檢舉" - } - } - } - }, - "Mute user" : { - "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Demp" - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "كتم" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Заглушаване" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Silenciar" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Skrýt" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lydløs" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Stummschalten" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Σίγαση" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mute user" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Silenciar" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mykistä" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Masquer" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Némítás" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Silenzia" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "השתקה" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ユーザーをミュート" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "사용자 뮤트" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dempen" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Demp" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wycisz" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Silenciar" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Silenciar" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ignoră" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Игнорировать" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Utišaj" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tysta" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sessize al" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Приглушити" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ẩn" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "静音用户" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "靜音使用者" - } - } - } - }, - "Block user" : { - "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Blokkeer" - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "حظر" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Блокиране" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bloquejar" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Blokovat" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bloker" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Blockieren" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Αποκλεισμός" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Block user" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bloquear" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Estä" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bloquer" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tiltás" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Blocca" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "חסימה" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ユーザーをブロック" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "사용자 차단" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Blokkeren" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Blokker" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zablokuj" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bloquear" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bloquear" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Blochează" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Заблокировать" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Blokiraj" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Blockera" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Engelle" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Заблокувати" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chặn" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "屏蔽用户" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "封鎖使用者" - } - } - } } }, "version" : "1.1" diff --git a/iosApp/flare/UI/Component/GalleryTimelinePagingView.swift b/iosApp/flare/UI/Component/GalleryTimelinePagingView.swift index 3c4c895b9..661da24ee 100644 --- a/iosApp/flare/UI/Component/GalleryTimelinePagingView.swift +++ b/iosApp/flare/UI/Component/GalleryTimelinePagingView.swift @@ -1265,11 +1265,11 @@ private final class GalleryFeedTileUIView: UIView { imageAspectConstraint?.isActive = true } else { let title = feed.title - let description = feed.description_ ?? feed.description + let description = feed.description_ titleLabel.text = title titleLabel.isHidden = title?.isEmpty ?? true descriptionLabel.text = description - descriptionLabel.isHidden = description.isEmpty + descriptionLabel.isHidden = description?.isEmpty ?? true textStack.isHidden = titleLabel.isHidden && descriptionLabel.isHidden } diff --git a/iosApp/flare/UI/Component/Status/FeedUIView.swift b/iosApp/flare/UI/Component/Status/FeedUIView.swift index b662ad9a1..5b0eac7a3 100644 --- a/iosApp/flare/UI/Component/Status/FeedUIView.swift +++ b/iosApp/flare/UI/Component/Status/FeedUIView.swift @@ -184,7 +184,8 @@ final class FeedUIView: UIView, ManualLayoutMeasurable, TimelineHeightProviding let descriptionHeight = descriptionLabel.isHidden ? 0 : estimatedLabelHeight(descriptionLabel, width: descriptionWidth) - let bodyHeight = max(descriptionHeight, mediaView.isHidden ? 0 : Self.mediaSize) + let mediaSize = bodyMediaSize(for: width) + let bodyHeight = max(descriptionHeight, mediaView.isHidden ? 0 : mediaSize.height) if assignFrames { if !descriptionLabel.isHidden { @@ -196,11 +197,12 @@ final class FeedUIView: UIView, ManualLayoutMeasurable, TimelineHeightProviding ) } if !mediaView.isHidden { + let mediaX = descriptionLabel.isHidden ? 0 : max(width - mediaSize.width, 0) mediaView.frame = CGRect( - x: max(width - Self.mediaSize, 0), + x: mediaX, y: y, - width: Self.mediaSize, - height: Self.mediaSize + width: mediaSize.width, + height: mediaSize.height ) } } @@ -283,7 +285,18 @@ final class FeedUIView: UIView, ManualLayoutMeasurable, TimelineHeightProviding if mediaView.isHidden { return width } - return max(width - Self.mediaSize - Self.spacing, 1) + if descriptionLabel.isHidden { + return width + } + return max(width - bodyMediaSize(for: width).width - Self.spacing, 1) + } + + private func bodyMediaSize(for width: CGFloat) -> CGSize { + guard !mediaView.isHidden else { return .zero } + if descriptionLabel.isHidden { + return CGSize(width: width, height: width * 9 / 16) + } + return CGSize(width: Self.mediaSize, height: Self.mediaSize) } private func estimatedLabelHeight(_ label: UILabel, width: CGFloat) -> CGFloat { @@ -333,8 +346,7 @@ final class FeedUIView: UIView, ManualLayoutMeasurable, TimelineHeightProviding titleLabel.text = nil } - let description = data.description_ ?? data.description - if !description.isEmpty { + if let description = data.description_, !description.isEmpty { descriptionLabel.isHidden = false descriptionLabel.text = description } else { diff --git a/iosApp/flare/UI/Component/Status/FeedView.swift b/iosApp/flare/UI/Component/Status/FeedView.swift index 89cef08db..20ec5c2b3 100644 --- a/iosApp/flare/UI/Component/Status/FeedView.swift +++ b/iosApp/flare/UI/Component/Status/FeedView.swift @@ -6,10 +6,13 @@ struct FeedView: View { let data: UiTimelineV2.Feed // @State private var showDetail = false private var descriptionText: String? { - let description = data.description_ ?? data.description - return description.isEmpty ? nil : description + guard let description = data.description_, !description.isEmpty else { + return nil + } + return description } var body: some View { + let desc = descriptionText VStack( alignment: .leading ) { @@ -36,19 +39,28 @@ struct FeedView: View { if let title = data.title { Text(title) } - HStack { - if let desc = descriptionText { - Text(desc) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(5) - .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) - } - if let image = data.media { - NetworkImage(data: image.url, customHeader: image.customHeaders) - .frame(width: 72, height: 72) - .clipShape(RoundedRectangle(cornerRadius: 8)) + if desc != nil || data.media != nil { + HStack(alignment: .top, spacing: 8) { + if let desc { + Text(desc) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(5) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + if let image = data.media { + NetworkImage(data: image.url, customHeader: image.customHeaders) + .if(desc != nil, transform: { view in + view.frame(width: 80, height: 80) + }) + .if(desc == nil, transform: { view in + view + .aspectRatio(16.0 / 9.0, contentMode: .fit) + .frame(maxWidth: .infinity) + }) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } } } } diff --git a/iosApp/flare/UI/Route/Route.swift b/iosApp/flare/UI/Route/Route.swift index b393e11cb..13bf972b0 100644 --- a/iosApp/flare/UI/Route/Route.swift +++ b/iosApp/flare/UI/Route/Route.swift @@ -10,6 +10,10 @@ enum Route: Hashable, Identifiable { switch (lhs, rhs) { case (.timeline(let lhs), .timeline(let rhs)): return lhs.id == rhs.id + case (.mediaRaw(let lhsMedias, let lhsIndex, let lhsPreview), .mediaRaw(let rhsMedias, let rhsIndex, let rhsPreview)): + return lhsIndex == rhsIndex && + lhsPreview == rhsPreview && + lhsMedias.map { $0.url } == rhsMedias.map { $0.url } default: return lhs.hashValue == rhs.hashValue } @@ -20,6 +24,11 @@ enum Route: Hashable, Identifiable { case .timeline(let item): hasher.combine("timeline") hasher.combine(item.id) + case .mediaRaw(let medias, let selectedIndex, let preview): + hasher.combine("mediaRaw") + hasher.combine(medias.map { $0.url }) + hasher.combine(selectedIndex) + hasher.combine(preview) default: hasher.combine(String(describing: self)) } @@ -131,6 +140,8 @@ enum Route: Hashable, Identifiable { RssDetailScreen(url: url, descriptionHtml: descriptionHtml, descriptionTitle: title) case .twitterArticle(let accountType, let tweetId, let articleId): TwitterArticleScreen(accountType: accountType, tweetId: tweetId, articleId: articleId) + case .article(let accountType, let articleKey): + ArticleScreen(accountType: accountType, articleKey: articleKey, onNavigate: onNavigate) case .statusVVOStatus(let accountType, let statusKey): VVOStatusScreen(accountType: accountType, statusKey: statusKey) case .statusShareSheet(let accountType, let statusKey, let shareUrl, let fxShareUrl, let fixvxShareUrl): @@ -155,7 +166,9 @@ enum Route: Hashable, Identifiable { DMConversationScreen(accountType: accountType, roomKey: roomKey) .navigationTitle(title) case .mediaImage(let url, let preview, let customHeaders): - MediaScreen(url: url, customHeaders: customHeaders) + MediaScreen(url: url, preview: preview, customHeaders: customHeaders) + case .mediaRaw(let medias, let selectedIndex, let preview): + RawMediaScreen(medias: medias, initialIndex: selectedIndex, preview: preview) case .mediaStatusMedia(let accountType, let statusKey, let selectedIndex, let preview): StatusMediaScreen(accountType: accountType, statusKey: statusKey, initialIndex: Int(selectedIndex), preview: preview) case .appLog: @@ -197,6 +210,7 @@ enum Route: Hashable, Identifiable { case composeReply(AccountType, MicroBlogKey) case composeVVOReplyComment(AccountType, MicroBlogKey, String) case mediaImage(String, String?, [String: String]?) + case mediaRaw([any UiMedia], Int, String?) case mediaPodcast(AccountType, String) case mediaStatusMedia(AccountType, MicroBlogKey, Int32, String?) case profileUser(AccountType, MicroBlogKey) @@ -204,6 +218,7 @@ enum Route: Hashable, Identifiable { case profileInsight(AccountType, MicroBlogKey) case rssDetail(String, String?, String?) case twitterArticle(AccountType, String, String?) + case article(AccountType, MicroBlogKey) case search(AccountType, String) case statusAddReaction(AccountType, MicroBlogKey) case statusAltText(String) @@ -362,6 +377,8 @@ enum Route: Hashable, Identifiable { } case .twitterArticle(let data): return Route.twitterArticle(data.accountType, data.tweetId, data.articleId) + case .article(let data): + return Route.article(data.accountType, data.articleKey) case .search(let search): return Route.search(search.accountType, search.query) case .status(let status): diff --git a/iosApp/flare/UI/Route/Router.swift b/iosApp/flare/UI/Route/Router.swift index 47d4e9d74..b9b3b1e38 100644 --- a/iosApp/flare/UI/Route/Router.swift +++ b/iosApp/flare/UI/Route/Router.swift @@ -116,7 +116,7 @@ struct Router: View { func isFullScreenCover(route: Route) -> Bool { switch route { - case .mediaStatusMedia, .mediaImage: + case .mediaStatusMedia, .mediaImage, .mediaRaw: return true default: return false diff --git a/iosApp/flare/UI/Screen/ArticleScreen.swift b/iosApp/flare/UI/Screen/ArticleScreen.swift new file mode 100644 index 000000000..d6a980cb2 --- /dev/null +++ b/iosApp/flare/UI/Screen/ArticleScreen.swift @@ -0,0 +1,569 @@ +import SwiftUI +import KotlinSharedUI + +struct ArticleScreen: View { + @StateObject private var presenter: KotlinPresenter + @Environment(\.openURL) private var openURL + + let accountType: AccountType + let articleKey: MicroBlogKey + let onNavigate: (Route) -> Void + + init( + accountType: AccountType, + articleKey: MicroBlogKey, + onNavigate: @escaping (Route) -> Void + ) { + self.accountType = accountType + self.articleKey = articleKey + self.onNavigate = onNavigate + self._presenter = .init(wrappedValue: .init( + presenter: ArticlePresenter(accountType: accountType, articleKey: articleKey) + )) + } + + var body: some View { + StateView(state: presenter.state.article) { article in + ArticleContentView( + article: article, + accountType: accountType, + onNavigate: onNavigate, + onOpenURL: openArticleURL + ) + } errorContent: { error in + ContentUnavailableView( + "Failed to load article", + systemImage: "exclamationmark.triangle", + description: Text(error.message ?? "Unknown error") + ) + } loadingContent: { + ArticleLoadingView() + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if let article = loadedArticle, + let sourceURL = article.sourceUrl.flatMap(URL.init(string:)) { + ToolbarItem { + Button { + openURL(sourceURL) + } label: { + Image(systemName: "safari") + } + .accessibilityLabel(Text("Open in Browser")) + } + ToolbarItem { + ShareLink( + item: sourceURL, + subject: Text(article.title), + message: Text(article.title), + preview: SharePreview(article.title) + ) { + Image(.faShareNodes) + } + .accessibilityLabel(Text("Share")) + } + } + } + } + + private var loadedArticle: UiArticle? { + switch onEnum(of: presenter.state.article) { + case .success(let data): + return data.data + case .loading, .error: + return nil + } + } + + private func openArticleURL(_ value: String) { + guard let url = URL(string: value) else { return } + openURL(url) + } +} + +private struct ArticleContentView: View { + let article: UiArticle + let accountType: AccountType + let onNavigate: (Route) -> Void + let onOpenURL: (String) -> Void + + private var blocks: [any UiArticleBlock] { + Array(article.content.blocks) + } + + var body: some View { + ScrollView { + LazyVStack(spacing: 16) { + if let cover = article.cover { + ArticleCoverView( + cover: cover, + title: article.title, + onOpenMedia: openMedia + ) + } + + LazyVStack(alignment: .leading, spacing: 16) { + ArticleHeaderView( + article: article, + accountType: accountType, + onNavigate: onNavigate + ) + + Divider() + + ForEach(blocks, id: \.key) { block in + ArticleBlockView( + block: block, + onOpenURL: onOpenURL, + onOpenMedia: openMedia + ) + } + } + .frame(maxWidth: 680, alignment: .leading) + .padding(.horizontal) + .padding(.vertical, 8) + .textSelection(.enabled) + } + .frame(maxWidth: .infinity) + .padding(.bottom, 24) + } + .ignoresSafeArea(edges: article.cover == nil ? Edge.Set() : .top) + } + + private var articleMedias: [any UiMedia] { + var medias: [any UiMedia] = [] + if let cover = article.cover { + medias.append(cover) + } + blocks.forEach { block in + switch onEnum(of: block) { + case .image(let image): + medias.append(image.media) + case .video(let video): + medias.append(video.media) + case .text, .file, .embed, .contentGate: + break + } + } + return medias + } + + private func openMedia(_ media: any UiMedia) { + let medias = articleMedias + let index = medias.firstIndex { $0.url == media.url } ?? 0 + onNavigate(.mediaRaw(medias, index, media.mediaPreviewURL)) + } +} + +private struct ArticleCoverView: View { + let cover: UiMediaImage + let title: String + let onOpenMedia: (any UiMedia) -> Void + + var body: some View { + Button { + onOpenMedia(cover) + } label: { + ArticleRemoteImage( + url: cover.url, + preview: cover.previewUrl, + customHeaders: cover.customHeaders + ) + .frame(height: 260) + .frame(maxWidth: .infinity) + .clipped() + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(Text(title)) + } +} + +private struct ArticleHeaderView: View { + let article: UiArticle + let accountType: AccountType + let onNavigate: (Route) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(article.title) + .font(.title2) + .fontWeight(.semibold) + .frame(maxWidth: .infinity, alignment: .leading) + + if let author = article.author { + switch onEnum(of: author) { + case .profile(let profileAuthor): + UserCompatView( + data: profileAuthor.profile, + trailing: { + Group { + if let publishDate = article.publishDate { + DateTimeText(data: publishDate, fullTime: true) + .font(.caption) + .foregroundStyle(.secondary) + } + } + }, + onClicked: { + onNavigate(.profileUser(accountType, profileAuthor.profile.key)) + } + ) + .frame(maxWidth: .infinity, alignment: .leading) + case .rss(let rssAuthor): + ArticleRssAuthorView( + author: rssAuthor, + sourceUrl: article.sourceUrl, + publishDate: article.publishDate + ) + } + } else if let publishDate = article.publishDate { + DateTimeText(data: publishDate, fullTime: true) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } +} + +private struct ArticleRssAuthorView: View { + let author: UiArticleAuthorRss + let sourceUrl: String? + let publishDate: UiDateTime? + + private var host: String? { + sourceUrl.flatMap(URL.init(string:))?.host() + } + + var body: some View { + if author.siteName != nil || author.byline != nil || publishDate != nil { + VStack(alignment: .leading, spacing: 4) { + if let siteName = author.siteName { + HStack(spacing: 4) { + if let host { + FavTabIcon(host: host) + .frame(width: 16, height: 16) + } else if let iconUrl = author.iconUrl { + NetworkImage(data: iconUrl) + .frame(width: 16, height: 16) + .clipShape(.rect(cornerRadius: 4)) + } + Text(siteName) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + HStack(spacing: 8) { + if let byline = author.byline { + Text(byline) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 8) + if let publishDate { + DateTimeText(data: publishDate, fullTime: true) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } +} + +private struct ArticleBlockView: View { + let block: any UiArticleBlock + let onOpenURL: (String) -> Void + let onOpenMedia: (any UiMedia) -> Void + + var body: some View { + switch onEnum(of: block) { + case .text(let text): + RichText(text: text.richText) + .frame(maxWidth: .infinity, alignment: .leading) + case .image(let image): + ArticleImageBlockView( + media: image.media, + onOpenMedia: onOpenMedia + ) + case .video(let video): + ArticleVideoBlockView( + media: video.media, + onOpenMedia: onOpenMedia + ) + case .file(let file): + ArticleFileBlockView(block: file, onOpenURL: onOpenURL) + case .embed(let embed): + ArticleEmbedBlockView(block: embed, onOpenURL: onOpenURL) + case .contentGate(let gate): + ArticleContentGateBlockView(block: gate, onOpenURL: onOpenURL) + } + } +} + +private struct ArticleImageBlockView: View { + let media: UiMediaImage + let onOpenMedia: (any UiMedia) -> Void + + var body: some View { + Button { + onOpenMedia(media) + } label: { + ArticleMediaFrame(aspectRatio: articleAspectRatio(media.aspectRatio)) { + ArticleRemoteImage( + url: media.url, + preview: media.previewUrl, + customHeaders: media.customHeaders + ) + } + } + .buttonStyle(.plain) + .accessibilityLabel(Text(media.description_ ?? "Image")) + } +} + +private struct ArticleVideoBlockView: View { + let media: UiMediaVideo + let onOpenMedia: (any UiMedia) -> Void + + var body: some View { + ArticleMediaFrame(aspectRatio: articleAspectRatio(media.aspectRatio)) { + MediaVideoView(data: media) + } + .contentShape(Rectangle()) + .overlay { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + onOpenMedia(media) + } + } + } +} + +private struct ArticleMediaFrame: View { + let aspectRatio: CGFloat + private let content: Content + + init( + aspectRatio: CGFloat, + @ViewBuilder content: () -> Content + ) { + self.aspectRatio = aspectRatio + self.content = content() + } + + var body: some View { + GeometryReader { proxy in + content + .frame(width: proxy.size.width, height: proxy.size.height) + .clipped() + } + .aspectRatio(aspectRatio, contentMode: .fit) + .frame(maxWidth: .infinity, alignment: .leading) + .clipShape(.rect(cornerRadius: 12)) + .contentShape(Rectangle()) + } +} + +private struct ArticleFileBlockView: View { + let block: UiArticleBlockFile + let onOpenURL: (String) -> Void + + private var extensionName: String? { + let extensionName = URL(string: block.url)?.pathExtension + return extensionName?.isEmpty == false ? extensionName?.uppercased() : nil + } + + var body: some View { + Button { + onOpenURL(block.url) + } label: { + HStack(spacing: 12) { + Image(systemName: "doc.fill") + .font(.title3) + .foregroundStyle(.secondary) + .frame(width: 28, height: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(block.name) + .font(.body) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + if let extensionName { + Text(extensionName) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Image("fa-download") + .foregroundStyle(.secondary) + .frame(width: 18, height: 18) + } + .padding(12) + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + +private struct ArticleEmbedBlockView: View { + let block: UiArticleBlockEmbed + let onOpenURL: (String) -> Void + + var body: some View { + Group { + if let url = block.url { + Button { + onOpenURL(url) + } label: { + content + } + .buttonStyle(.plain) + } else { + content + } + } + } + + private var content: some View { + HStack(spacing: 12) { + if let imageUrl = block.imageUrl { + NetworkImage(data: imageUrl) + .frame(width: 64, height: 64) + .clipShape(.rect(cornerRadius: 8)) + } else { + Image("fa-globe") + .font(.title3) + .foregroundStyle(.secondary) + .frame(width: 28, height: 28) + } + + VStack(alignment: .leading, spacing: 4) { + Text(block.title ?? block.url ?? block.htmlFallback ?? "") + .font(.body) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + if let description = block.description_ { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + } + if let url = block.url { + Text(url) + .font(.caption) + .foregroundStyle(.tint) + .lineLimit(1) + } + } + } + .padding(12) + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .contentShape(Rectangle()) + } +} + +private struct ArticleContentGateBlockView: View { + let block: UiArticleBlockContentGate + let onOpenURL: (String) -> Void + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image("fa-lock") + .font(.title3) + .foregroundStyle(.tint) + .frame(width: 28, height: 28) + + VStack(alignment: .leading, spacing: 8) { + Text("Subscription required") + .font(.body.weight(.semibold)) + Text(descriptionText) + .font(.body) + .foregroundStyle(.secondary) + + if let actionUrl = block.actionUrl, !actionUrl.isEmpty { + Button { + onOpenURL(actionUrl) + } label: { + Text("Open in Browser") + } + .buttonStyle(.borderedProminent) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(16) + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + + private var descriptionText: String { + switch onEnum(of: block.reason) { + case .subscriptionRequired: + return "This article contains subscriber-only content." + } + } +} + +private struct ArticleRemoteImage: View { + let url: String + let preview: String? + let customHeaders: [String: String]? + + var body: some View { + if let preview { + NetworkImage(data: url, placeholder: preview, customHeader: customHeaders) + } else { + NetworkImage(data: url, customHeader: customHeaders) + } + } +} + +private struct ArticleLoadingView: View { + var body: some View { + ScrollView { + LazyVStack { + LazyVStack(alignment: .leading, spacing: 16) { + Rectangle() + .fill(.placeholder) + .frame(height: 32) + .clipShape(.rect(cornerRadius: 8)) + HStack(spacing: 12) { + Circle() + .fill(.placeholder) + .frame(width: 44, height: 44) + VStack(alignment: .leading, spacing: 8) { + Rectangle() + .fill(.placeholder) + .frame(width: 160, height: 14) + Rectangle() + .fill(.placeholder) + .frame(width: 96, height: 12) + } + } + Divider() + ForEach(0..<6, id: \.self) { index in + Rectangle() + .fill(.placeholder) + .frame(height: index == 5 ? 80 : 16) + .clipShape(.rect(cornerRadius: 8)) + } + } + .redacted(reason: .placeholder) + .frame(maxWidth: 680, alignment: .leading) + .padding() + } + .frame(maxWidth: .infinity) + } + } +} + +private func articleAspectRatio(_ value: Float) -> CGFloat { + let ratio = CGFloat(value) + guard ratio.isFinite, ratio > 0 else { return 16 / 9 } + return min(max(ratio, 0.2), 4) +} diff --git a/iosApp/flare/UI/Screen/GalleryDetailScreen.swift b/iosApp/flare/UI/Screen/GalleryDetailScreen.swift index 8f69c6628..2e877eacc 100644 --- a/iosApp/flare/UI/Screen/GalleryDetailScreen.swift +++ b/iosApp/flare/UI/Screen/GalleryDetailScreen.swift @@ -170,10 +170,9 @@ struct GalleryDetailScreen: View { } private func navigateToMedia(post: UiTimelineV2.Post, media: UiMedia) { - let route = post.statusMediaRoute(media: media) - if let url = URL(string: route.toUri()) { - openURL(url) - } + let medias = post.galleryImages.map { $0 as any UiMedia } + let index = medias.firstIndex { $0.url == media.url } ?? 0 + onNavigate(.mediaRaw(medias, index, media.mediaPreviewURL)) } } diff --git a/iosApp/flare/UI/Screen/MediaScreen.swift b/iosApp/flare/UI/Screen/MediaScreen.swift index 70f5a0eb1..5f6543423 100644 --- a/iosApp/flare/UI/Screen/MediaScreen.swift +++ b/iosApp/flare/UI/Screen/MediaScreen.swift @@ -1,99 +1,52 @@ import SwiftUI import KotlinSharedUI -import LazyPager + +struct RawMediaScreen: View { + let medias: [any UiMedia] + let initialIndex: Int + let preview: String? + + var body: some View { + MediaViewerScreen( + medias: medias, + initialIndex: initialIndex, + preview: preview + ) + } +} struct MediaScreen: View { let url: String + let preview: String? let customHeaders: [String: String]? - @Environment(\.dismiss) var dismiss - @State var opacity: CGFloat = 1 // Dismiss gesture background opacity - @State private var shareFileURL: URL? - @State private var shareFileSourceURL: String? - @State private var isLandscapeViewing: Bool = false - var body: some View { - LazyPager(data: [url]) { item in - AdaptiveKFImage(data: item, placeholder: nil, customHeader: customHeaders) - } - .onDismiss(backgroundOpacity: $opacity) { - dismiss() - } - .zoomable(min: 1, max: 5) - .settings { config in - config.preloadAmount = 99 - } - .task(id: url) { - await loadShareFile(url: url, customHeaders: customHeaders) - } - .onChange(of: isLandscapeViewing) { _, newValue in - MediaOrientationController.setLandscape(newValue) - } - .onDisappear { - if isLandscapeViewing { - MediaOrientationController.setLandscape(false) - } - } - .background(.black.opacity(opacity)) - .background(ClearFullScreenBackground()) - .ignoresSafeArea() - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button { - dismiss() - } label: { - Image("fa-xmark") - } - } - ToolbarItem(placement: .primaryAction) { - Button { - withAnimation(.easeInOut(duration: 0.2)) { - isLandscapeViewing.toggle() - } - } label: { - Image(systemName: isLandscapeViewing ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right") - } - .accessibilityLabel(Text(verbatim: isLandscapeViewing ? "Exit landscape view" : "Landscape view")) - } - ToolbarItem(placement: .primaryAction) { - Button { - MediaSaver.shared.saveImage(url: url, customHeaders: customHeaders) - } label: { - Image("fa-download") - } - } - ToolbarItem(placement: .primaryAction) { - if let shareFileURL, shareFileSourceURL == url { - ShareLink(item: shareFileURL) { - Image("fa-share-nodes") - } - .accessibilityLabel("Share image") - } else { - Button { - } label: { - Image("fa-share-nodes") - } - .disabled(true) - .accessibilityLabel("Share image") - } - } - } + init( + url: String, + preview: String? = nil, + customHeaders: [String: String]? + ) { + self.url = url + self.preview = preview + self.customHeaders = customHeaders } - private func loadShareFile(url: String, customHeaders: [String: String]?) async { - shareFileURL = nil - shareFileSourceURL = url + var body: some View { + RawMediaScreen( + medias: [media], + initialIndex: 0, + preview: preview ?? url + ) + } - do { - let fileURL = try await OriginalImageShareFile.make(url: url, customHeaders: customHeaders) - guard !Task.isCancelled, shareFileSourceURL == url else { - return - } - shareFileURL = fileURL - } catch { - guard !Task.isCancelled, shareFileSourceURL == url else { - return - } - shareFileURL = nil - } + private var media: UiMediaImage { + UiMediaImage( + url: url, + previewUrl: preview ?? url, + description: nil, + height: 0, + width: 0, + sensitive: false, + customHeaders: customHeaders + ) } } diff --git a/iosApp/flare/UI/Screen/MediaViewerScreen.swift b/iosApp/flare/UI/Screen/MediaViewerScreen.swift new file mode 100644 index 000000000..5a8f10f0c --- /dev/null +++ b/iosApp/flare/UI/Screen/MediaViewerScreen.swift @@ -0,0 +1,481 @@ +import SwiftUI +import KotlinSharedUI +import LazyPager +import AVKit +import SwiftUIBackports +import UIKit + +struct MediaViewerShareContext { + let statusKey: String? + let userHandle: String? +} + +struct MediaViewerOverlayContext { + let selectedMedia: (any UiMedia)? + let mediaCount: Int + let selectedIndex: Binding + let showData: Bool + let isLandscapeViewing: Bool + let isPlaying: Binding + let currentTime: Binding + let videoState: VideoState + let playbackRate: Float +} + +struct MediaViewerScreen: View { + @Environment(\.dismiss) private var dismiss + + let medias: [any UiMedia] + let initialIndex: Int + let preview: String? + let shareContext: MediaViewerShareContext? + let showsSupplementaryOverlay: Bool + @ViewBuilder let supplementaryOverlay: (MediaViewerOverlayContext) -> SupplementaryOverlay + + @State private var selectedIndex: Int + @State private var isPlaying: Bool = true + @State private var videoState: VideoState = .idle + @State private var currentTime: CMTime = .zero + @State private var opacity: CGFloat = 1 + @State private var showData = true + @State private var protectInitialPagerSelection: Bool + @State private var didApplyInitialSelection = false + @State private var shareFileURL: URL? + @State private var shareFileSourceURL: String? + @State private var holdsPlaybackSession = false + @State private var playbackRate: Float = 1 + @State private var isLandscapeViewing = false + + init( + medias: [any UiMedia], + initialIndex: Int, + preview: String?, + shareContext: MediaViewerShareContext? = nil, + showsSupplementaryOverlay: Bool = false, + @ViewBuilder supplementaryOverlay: @escaping (MediaViewerOverlayContext) -> SupplementaryOverlay + ) { + self.medias = medias + self.initialIndex = initialIndex + self.preview = preview + self.shareContext = shareContext + self.showsSupplementaryOverlay = showsSupplementaryOverlay + self.supplementaryOverlay = supplementaryOverlay + self._selectedIndex = .init(initialValue: max(0, initialIndex)) + self._protectInitialPagerSelection = .init(initialValue: initialIndex > 0) + } + + var body: some View { + ZStack { + if medias.isEmpty { + if let preview { + AdaptiveKFImage(data: preview, placeholder: nil) + } else { + ProgressView() + } + } else { + LazyPager(data: medias, page: pagerSelectedIndex) { media in + mediaContent(media) + } + .onDismiss(backgroundOpacity: $opacity) { + dismiss() + } + .onTap { + withAnimation { + showData.toggle() + } + } + .onDoubleTap {} + .onDrag { + protectInitialPagerSelection = false + } + .zoomable { item in + if item.isVideoMedia { + return .disabled + } else { + return .custom(min: 1, max: 5, doubleTap: .scale(2)) + } + } + .settings { config in + config.preloadAmount = 99 + } + .overlay(alignment: .bottom) { + if shouldShowBottomOverlay { + bottomOverlay + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .task(id: selectedShareFileIdentity) { + await loadShareFile(url: selectedImageURL, customHeaders: selectedImageCustomHeaders) + } + .onAppear { + applyInitialSelectionIfNeeded() + } + .onChange(of: mediaSignature) { _, _ in + applyInitialSelectionIfNeeded() + } + .onChange(of: selectedIndex) { _, _ in + isPlaying = true + videoState = .idle + currentTime = .zero + playbackRate = 1 + } + .onChange(of: isVideoActivelyPlaying) { _, newValue in + updatePlaybackSession(playing: newValue) + } + .onChange(of: isLandscapeViewing) { _, newValue in + MediaOrientationController.setLandscape(newValue) + } + .onDisappear { + if holdsPlaybackSession { + AudioSessionManager.shared.endPlayback() + holdsPlaybackSession = false + } + if isLandscapeViewing { + MediaOrientationController.setLandscape(false) + } + } + .background(.black.opacity(opacity)) + .background(ClearFullScreenBackground()) + .ignoresSafeArea() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + dismiss() + } label: { + Image("fa-xmark") + } + } + if !medias.isEmpty { + ToolbarItem(placement: .primaryAction) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isLandscapeViewing.toggle() + } + } label: { + Image(systemName: isLandscapeViewing ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right") + } + .accessibilityLabel(Text(verbatim: isLandscapeViewing ? "Exit landscape view" : "Landscape view")) + } + if let selectedMedia, case .image = onEnum(of: selectedMedia) { + ToolbarItem(placement: .primaryAction) { + Button { + MediaSaver.shared.saveImage(url: selectedMedia.url, customHeaders: selectedMedia.customHeaders) + } label: { + Image("fa-download") + } + } + ToolbarItem(placement: .primaryAction) { + if let shareFileURL, shareFileSourceURL == selectedMedia.url { + ShareLink(item: shareFileURL) { + Image("fa-share-nodes") + } + .accessibilityLabel("Share image") + } else { + Button { + } label: { + Image("fa-share-nodes") + } + .disabled(true) + .accessibilityLabel("Share image") + } + } + } else if let selectedMedia, case .video(let video) = onEnum(of: selectedMedia) { + ToolbarItem(placement: .primaryAction) { + Button { + MediaSaver.shared.saveVideo(url: video.url, customHeaders: video.customHeaders) + } label: { + Image("fa-download") + } + } + } + } + } + } + + @ViewBuilder + private func mediaContent(_ media: any UiMedia) -> some View { + switch onEnum(of: media) { + case .image(let image): + AdaptiveKFImage(data: image.url, placeholder: image.previewUrl, customHeader: image.customHeaders) + case .video(let video): + if medias.indices.contains(selectedIndex), medias[selectedIndex].url == video.url { + StatusMediaVideoView( + data: video, + play: $isPlaying, + videoState: $videoState, + time: $currentTime, + playbackRate: $playbackRate + ) + } else { + NetworkImage(data: video.thumbnailUrl, customHeader: video.customHeaders) + .scaledToFit() + } + case .gif(let gif): + NetworkImage(data: gif.url, placeholder: gif.previewUrl, customHeader: gif.customHeaders) + .scaledToFit() + case .audio: + EmptyView() + } + } + + @ViewBuilder + private var bottomOverlay: some View { + if #available(iOS 26.0, *) { + bottomOverlayContent + .padding() + .backport + .glassEffect(.tinted(.init(.systemGroupedBackground).opacity(0.5)), in: .rect(corners: .concentric, isUniform: true), fallbackBackground: .regularMaterial) + .padding() + .transition(.move(edge: .bottom).combined(with: .opacity)) + } else { + bottomOverlayContent + .padding() + .safeAreaPadding(.bottom) + .backport + .glassEffect(.regular, in: .rect(cornerRadius: 24, style: .continuous), fallbackBackground: .regularMaterial) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + + private var bottomOverlayContent: some View { + VStack(spacing: 8) { + if showData, !isLandscapeViewing, medias.count > 1 { + if !showsSupplementaryOverlay, medias.count > 10 { + MediaPageSlider(count: medias.count, page: $selectedIndex) + } else { + LazyPagerIndicator(count: medias.count, page: $selectedIndex) + } + } + + if let selectedMedia, case .video = onEnum(of: selectedMedia) { + VideoControlView( + isPlaying: $isPlaying, + currentTime: $currentTime, + videoState: videoState, + playbackRate: playbackRate + ) + } + + if showData, !isLandscapeViewing, showsSupplementaryOverlay { + supplementaryOverlay(overlayContext) + } + } + } + + private var overlayContext: MediaViewerOverlayContext { + MediaViewerOverlayContext( + selectedMedia: selectedMedia, + mediaCount: medias.count, + selectedIndex: $selectedIndex, + showData: showData, + isLandscapeViewing: isLandscapeViewing, + isPlaying: $isPlaying, + currentTime: $currentTime, + videoState: videoState, + playbackRate: playbackRate + ) + } + + private var pagerSelectedIndex: Binding { + Binding( + get: { + selectedIndex + }, + set: { newValue in + let nextIndex = clampedIndex(newValue, count: medias.count) + if protectInitialPagerSelection, + selectedIndex > 0, + nextIndex < selectedIndex { + return + } + protectInitialPagerSelection = false + selectedIndex = nextIndex + } + ) + } + + private var selectedMedia: (any UiMedia)? { + guard medias.indices.contains(selectedIndex) else { + return nil + } + return medias[selectedIndex] + } + + private var selectedImageURL: String? { + guard let selectedMedia else { + return nil + } + + switch onEnum(of: selectedMedia) { + case .image(let image): + return image.url + case .video, .gif, .audio: + return nil + } + } + + private var selectedImageCustomHeaders: [String: String]? { + selectedMedia?.customHeaders + } + + private var selectedShareFileIdentity: String { + [ + selectedImageURL, + shareContext?.statusKey, + shareContext?.userHandle, + ].compactMap { $0 }.joined(separator: "|") + } + + private var isVideoActivelyPlaying: Bool { + if case .playing = videoState { return true } + return false + } + + private var selectedMediaIsVideo: Bool { + selectedMedia?.isVideoMedia == true + } + + private var shouldShowBottomOverlay: Bool { + if selectedMediaIsVideo { + return showData || playbackRate > 1 + } + return showData && !isLandscapeViewing && (medias.count > 1 || showsSupplementaryOverlay) + } + + private var mediaSignature: String { + medias.map { $0.url }.joined(separator: "\n") + } + + private func applyInitialSelectionIfNeeded() { + guard !medias.isEmpty else { + return + } + if !didApplyInitialSelection || selectedIndex >= medias.count { + let initialSelection = clampedIndex(initialIndex, count: medias.count) + selectedIndex = initialSelection + protectInitialPagerSelection = initialSelection > 0 + didApplyInitialSelection = true + } + } + + private func updatePlaybackSession(playing: Bool) { + if playing, !holdsPlaybackSession { + AudioSessionManager.shared.beginPlayback() + holdsPlaybackSession = true + } else if !playing, holdsPlaybackSession { + AudioSessionManager.shared.endPlayback() + holdsPlaybackSession = false + } + } + + private func clampedIndex(_ index: Int, count: Int) -> Int { + guard count > 0 else { + return 0 + } + return min(max(index, 0), count - 1) + } + + private func loadShareFile(url: String?, customHeaders: [String: String]?) async { + shareFileURL = nil + shareFileSourceURL = url + + guard let url else { + return + } + + do { + let fileURL = try await OriginalImageShareFile.make( + url: url, + customHeaders: customHeaders, + statusKey: shareContext?.statusKey, + userHandle: shareContext?.userHandle + ) + guard !Task.isCancelled, shareFileSourceURL == url else { + return + } + shareFileURL = fileURL + } catch { + guard !Task.isCancelled, shareFileSourceURL == url else { + return + } + shareFileURL = nil + } + } +} + +private struct MediaPageSlider: View { + let count: Int + @Binding var page: Int + + var body: some View { + let maxPage = max(count - 1, 0) + let displayedPage = clampedPage(page, maxPage: maxPage) + + HStack(spacing: 10) { + Text(verbatim: "\(displayedPage + 1)") + .frame(minWidth: 24, alignment: .trailing) + + Slider( + value: Binding( + get: { + Double(displayedPage) + }, + set: { newValue in + page = clampedPage(Int(newValue.rounded()), maxPage: maxPage) + } + ), + in: 0...Double(maxPage), + step: 1 + ) + + Text(verbatim: "\(count)") + .frame(minWidth: 24, alignment: .leading) + } + .font(.caption.weight(.semibold)) + .monospacedDigit() + .frame(minWidth: 220, maxWidth: 360) + } + + private func clampedPage(_ value: Int, maxPage: Int) -> Int { + min(max(value, 0), maxPage) + } +} + +extension MediaViewerScreen where SupplementaryOverlay == EmptyView { + init( + medias: [any UiMedia], + initialIndex: Int, + preview: String?, + shareContext: MediaViewerShareContext? = nil + ) { + self.init( + medias: medias, + initialIndex: initialIndex, + preview: preview, + shareContext: shareContext, + showsSupplementaryOverlay: false + ) { _ in + EmptyView() + } + } +} + +extension UiMedia { + var mediaPreviewURL: String? { + switch onEnum(of: self) { + case .image(let image): image.previewUrl + case .video(let video): video.thumbnailUrl + case .gif(let gif): gif.previewUrl + case .audio: nil + } + } + + var isVideoMedia: Bool { + if case .video = onEnum(of: self) { + return true + } + return false + } +} diff --git a/iosApp/flare/UI/Screen/StatusMediaScreen.swift b/iosApp/flare/UI/Screen/StatusMediaScreen.swift index 8539d48b1..ada3054c4 100644 --- a/iosApp/flare/UI/Screen/StatusMediaScreen.swift +++ b/iosApp/flare/UI/Screen/StatusMediaScreen.swift @@ -10,308 +10,56 @@ import Combine import UIKit struct StatusMediaScreen: View { - @Environment(\.dismiss) var dismiss let accountType: AccountType let statusKey: MicroBlogKey let initialIndex: Int let preview: String? @StateObject private var presenter: KotlinPresenter @State private var medias: [any UiMedia] = [] - @State private var selectedIndex: Int = 0 - @State private var isPlaying: Bool = true - @State private var videoState: VideoState = .idle - @State private var currentTime: CMTime = .zero - @State var opacity: CGFloat = 1 // Dismiss gesture background opacity - @State var showData = true - @State private var protectInitialPagerSelection: Bool = false - @State private var shareFileURL: URL? - @State private var shareFileSourceURL: String? - @State private var holdsPlaybackSession: Bool = false - @State private var playbackRate: Float = 1 - @State private var isLandscapeViewing: Bool = false var body: some View { - ZStack { - if medias.isEmpty { - if let preview { - AdaptiveKFImage(data: preview, placeholder: nil) - } else { - ProgressView() - } - } else { - LazyPager(data: medias, page: pagerSelectedIndex) { media in - switch onEnum(of: media) { - case .image(let image): - AdaptiveKFImage(data: image.url, placeholder: image.previewUrl, customHeader: image.customHeaders) - case .video(let video): - if selectedIndex == medias.firstIndex(where: { $0.url == video.url }) { - StatusMediaVideoView( - data: video, - play: $isPlaying, - videoState: $videoState, - time: $currentTime, - playbackRate: $playbackRate - ) - } else { - NetworkImage(data: video.thumbnailUrl, customHeader: video.customHeaders) - .scaledToFit() - } - case .gif(let gif): - NetworkImage(data: gif.url, placeholder: gif.previewUrl, customHeader: gif.customHeaders) - .scaledToFit() - case .audio: - EmptyView() - } - } - .onDismiss(backgroundOpacity: $opacity) { - dismiss() - } - .onTap { - withAnimation { - showData = !showData - } - } - .onDoubleTap {} - .onDrag { - protectInitialPagerSelection = false - } - .zoomable { item in - if isVideoMedia(item) { - return .disabled - } else { - return .custom(min: 1, max: 5, doubleTap: .scale(2)) - } - } - .settings { config in - config.preloadAmount = 99 - } - .overlay(alignment: .bottom) { - if shouldShowStatusOverlay { - if #available(iOS 26.0, *) { - statusView - .padding() - .backport - .glassEffect(.tinted(.init(.systemGroupedBackground).opacity(0.5)), in: .rect(corners: .concentric, isUniform: true), fallbackBackground: .regularMaterial) - .padding() - .transition(.move(edge: .bottom).combined(with: .opacity)) - } else { - statusView - .padding() - .safeAreaPadding(.bottom) - .backport - .glassEffect(.regular, in: .rect(), fallbackBackground: .regularMaterial) - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - } + MediaViewerScreen( + medias: medias, + initialIndex: initialIndex, + preview: preview, + shareContext: MediaViewerShareContext( + statusKey: statusKey.description(), + userHandle: statusUserHandle + ), + showsSupplementaryOverlay: true + ) { _ in + StateView(state: presenter.state.status) { timeline in + if let content = timeline as? UiTimelineV2.Post { + StatusView( + data: content, + isQuote: true, + showMedia: false, + maxLine: 3, + showExpandTextButton: false, + showParents: false + ) } } } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .task(id: selectedImageURL) { - await loadShareFile(url: selectedImageURL, customHeaders: selectedImageCustomHeaders) - } - .onChange(of: selectedIndex) { oldValue, newValue in - isPlaying = true - videoState = .idle - currentTime = .zero - playbackRate = 1 - } - .onChange(of: isVideoActivelyPlaying) { _, newValue in - updatePlaybackSession(playing: newValue) - } - .onChange(of: isLandscapeViewing) { _, newValue in - MediaOrientationController.setLandscape(newValue) - } - .onDisappear { - if holdsPlaybackSession { - AudioSessionManager.shared.endPlayback() - holdsPlaybackSession = false - } - if isLandscapeViewing { - MediaOrientationController.setLandscape(false) - } + .onAppear { + syncMediasIfNeeded(animated: false) } .onChange(of: presenter.state.status) { oldValue, newValue in - if medias.isEmpty, - case .success(let success) = onEnum(of: newValue), - let content = success.data as? UiTimelineV2.Post { - let contentMedias = Array(content.images) - let initialSelection = clampedIndex(initialIndex, count: contentMedias.count) - selectedIndex = initialSelection - protectInitialPagerSelection = initialSelection > 0 - withAnimation { - medias = contentMedias - } - } - } - .background(.black.opacity(opacity)) - .background(ClearFullScreenBackground()) - .ignoresSafeArea() - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button { - dismiss() - } label: { - Image("fa-xmark") - } - } - if !medias.isEmpty { - ToolbarItem(placement: .primaryAction) { - Button { - withAnimation(.easeInOut(duration: 0.2)) { - isLandscapeViewing.toggle() - } - } label: { - Image(systemName: isLandscapeViewing ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right") - } - .accessibilityLabel(Text(verbatim: isLandscapeViewing ? "Exit landscape view" : "Landscape view")) - } - if let selectedMedia, case .image = onEnum(of: selectedMedia) { - ToolbarItem(placement: .primaryAction) { - Button { - MediaSaver.shared.saveImage(url: selectedMedia.url, customHeaders: selectedMedia.customHeaders) - } label: { - Image("fa-download") - } - } - ToolbarItem(placement: .primaryAction) { - if let shareFileURL, shareFileSourceURL == selectedMedia.url { - ShareLink(item: shareFileURL) { - Image("fa-share-nodes") - } - .accessibilityLabel("Share image") - } else { - Button { - } label: { - Image("fa-share-nodes") - } - .disabled(true) - .accessibilityLabel("Share image") - } - } - } else if let selectedMedia, case .video(let video) = onEnum(of: selectedMedia) { - ToolbarItem(placement: .primaryAction) { - Button { - MediaSaver.shared.saveVideo(url: video.url, customHeaders: video.customHeaders) - } label: { - Image("fa-download") - } - } - } - } + syncMediasIfNeeded(animated: true) } } - private var pagerSelectedIndex: Binding { - Binding( - get: { - selectedIndex - }, - set: { newValue in - let nextIndex = clampedIndex(newValue, count: medias.count) - if protectInitialPagerSelection, - selectedIndex > 0, - nextIndex < selectedIndex { - return + private func syncMediasIfNeeded(animated: Bool) { + if medias.isEmpty, + case .success(let success) = onEnum(of: presenter.state.status), + let content = success.data as? UiTimelineV2.Post { + if animated { + withAnimation { + medias = Array(content.images) } - protectInitialPagerSelection = false - selectedIndex = nextIndex - } - ) - } - - private var selectedMedia: (any UiMedia)? { - guard medias.indices.contains(selectedIndex) else { - return nil - } - return medias[selectedIndex] - } - - private var selectedImageURL: String? { - guard let selectedMedia else { - return nil - } - - switch onEnum(of: selectedMedia) { - case .image(let image): - return image.url - case .video, .gif, .audio: - return nil - } - } - - private var selectedImageCustomHeaders: [String: String]? { - guard let selectedMedia else { - return nil - } - return selectedMedia.customHeaders - } - - private var isVideoActivelyPlaying: Bool { - if case .playing = videoState { return true } - return false - } - - private var selectedMediaIsVideo: Bool { - guard let selectedMedia else { return false } - return isVideoMedia(selectedMedia) - } - - private var shouldShowStatusOverlay: Bool { - if selectedMediaIsVideo { - return showData || playbackRate > 1 - } - return showData && !isLandscapeViewing - } - - private func isVideoMedia(_ media: any UiMedia) -> Bool { - if case .video = onEnum(of: media) { - return true - } - return false - } - - private func updatePlaybackSession(playing: Bool) { - if playing, !holdsPlaybackSession { - AudioSessionManager.shared.beginPlayback() - holdsPlaybackSession = true - } else if !playing, holdsPlaybackSession { - AudioSessionManager.shared.endPlayback() - holdsPlaybackSession = false - } - } - - private func clampedIndex(_ index: Int, count: Int) -> Int { - guard count > 0 else { - return 0 - } - return min(max(index, 0), count - 1) - } - - private func loadShareFile(url: String?, customHeaders: [String: String]?) async { - shareFileURL = nil - shareFileSourceURL = url - - guard let url else { - return - } - - do { - let fileURL = try await OriginalImageShareFile.make( - url: url, - customHeaders: customHeaders, - statusKey: statusKey.description(), - userHandle: statusUserHandle - ) - guard !Task.isCancelled, shareFileSourceURL == url else { - return - } - shareFileURL = fileURL - } catch { - guard !Task.isCancelled, shareFileSourceURL == url else { - return + } else { + medias = Array(content.images) } - shareFileURL = nil } } @@ -322,35 +70,6 @@ struct StatusMediaScreen: View { } return "unknown" } - - var statusView: some View { - VStack( - spacing: 8, - ) { - if showData, !isLandscapeViewing, medias.count > 1 { - LazyPagerIndicator(count: medias.count, page: $selectedIndex) - } - - if let selectedMedia { - if case .video = onEnum(of: selectedMedia) { - VideoControlView( - isPlaying: $isPlaying, - currentTime: $currentTime, - videoState: videoState, - playbackRate: playbackRate - ) - } - } - - if showData, !isLandscapeViewing { - StateView(state: presenter.state.status) { timeline in - if let content = timeline as? UiTimelineV2.Post { - StatusView(data: content, isQuote: true, showMedia: false, maxLine: 3, showExpandTextButton: false, showParents: false) - } - } - } - } - } } struct LazyPagerIndicator: View { @@ -507,8 +226,6 @@ extension StatusMediaScreen { self.statusKey = statusKey self.initialIndex = initialIndex self.preview = preview - self._selectedIndex = .init(initialValue: max(0, initialIndex)) - self._protectInitialPagerSelection = .init(initialValue: initialIndex > 0) self._presenter = .init(wrappedValue: .init(presenter: StatusPresenter(accountType: accountType, statusKey: statusKey))) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/tab/Timeline.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/tab/Timeline.kt index fef7956fe..4fc934640 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/tab/Timeline.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/tab/Timeline.kt @@ -24,6 +24,7 @@ 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.model.asType +import dev.dimension.flare.ui.presenter.home.SystemHomeMixedTimelinePresenter import dev.dimension.flare.ui.presenter.home.TimelinePresenter import dev.dimension.flare.ui.route.DeeplinkRoute import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -721,10 +722,14 @@ internal class TimelinePresenterFactory( private val timelineResolver: TimelineResolver, ) { fun create(item: UiTimelineTabItem): TimelinePresenter = - TimelinePresenter( - tabId = item.id, - loader = timelineResolver.resolveLoader(item), - ) + if (item.isSystemHomeMixedTimeline) { + SystemHomeMixedTimelinePresenter(item.id) + } else { + TimelinePresenter( + tabId = item.id, + loader = timelineResolver.resolveLoader(item), + ) + } } internal object MixedTimelineLoaderFactory : KoinComponent { diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenterBindingTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenterBindingTest.kt index ab6c986c8..1b149c962 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenterBindingTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/home/TimelinePresenterBindingTest.kt @@ -5,11 +5,19 @@ import dev.dimension.flare.createTestRootPath import dev.dimension.flare.data.datastore.AppDataStore import dev.dimension.flare.data.io.OkioFileStorage import dev.dimension.flare.data.model.HomeTimelineTabItem +import dev.dimension.flare.data.model.tab.GroupSource +import dev.dimension.flare.data.model.tab.SYSTEM_HOME_MIXED_TIMELINE_ID import dev.dimension.flare.data.model.tab.TabSettingsV2 import dev.dimension.flare.data.model.tab.TimelineFilterConfig +import dev.dimension.flare.data.model.tab.TimelineMergePolicy import dev.dimension.flare.data.model.tab.TimelinePostKind import dev.dimension.flare.data.model.tab.TimelinePresentation +import dev.dimension.flare.data.model.tab.TimelinePresenterFactory import dev.dimension.flare.data.model.tab.TimelineResolver +import dev.dimension.flare.data.model.tab.TimelineSlot +import dev.dimension.flare.data.model.tab.TimelineSlotContent +import dev.dimension.flare.data.model.tab.UiGroupTimelineTabItem +import dev.dimension.flare.data.model.tab.isSystemHomeMixedTimeline import dev.dimension.flare.data.model.tab.toTimelineSlotOrNull import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.deleteTestRootPath @@ -18,6 +26,7 @@ import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.testPlatformRuntimeData import dev.dimension.flare.unavailableAccountService import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch @@ -29,20 +38,24 @@ import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotEquals class TimelinePresenterBindingTest { private lateinit var root: Path + private lateinit var timelineResolver: TimelineResolver private lateinit var settingsRepository: SettingsRepository @BeforeTest fun setup() { root = createTestRootPath() val fileStorage = OkioFileStorage(createTestFileSystem(), root) + timelineResolver = TimelineResolver(testPlatformRuntimeData(), unavailableAccountService()) settingsRepository = SettingsRepository( fileStorage = fileStorage, appDataStore = AppDataStore(fileStorage), - timelineResolver = TimelineResolver(testPlatformRuntimeData(), unavailableAccountService()), + timelineResolver = timelineResolver, ) } @@ -106,6 +119,99 @@ class TimelinePresenterBindingTest { ) } + @Test + fun systemHomeMixedTimelineKeepsStableIdButChangesLoaderKeyWhenChildIsDisabled() = + runTest { + val firstSlot = homeSlot(accountId = "first") + val secondSlot = homeSlot(accountId = "second") + settingsRepository.updateTabSettingsV2 { + TabSettingsV2( + homeSlots = + listOf( + systemHomeMixedSlot(firstSlot, secondSlot), + firstSlot, + secondSlot, + ), + ) + } + val initialMixedTab = systemHomeMixedTab() + + val disabledSecondSlot = secondSlot.copy(presentation = TimelinePresentation(enabled = false)) + settingsRepository.updateTabSettingsV2 { + TabSettingsV2( + homeSlots = + listOf( + systemHomeMixedSlot(firstSlot, disabledSecondSlot), + firstSlot, + disabledSecondSlot, + ), + ) + } + val updatedMixedTab = systemHomeMixedTab() + + assertEquals(initialMixedTab.id, updatedMixedTab.id) + assertEquals( + listOf(true, true), + initialMixedTab.children.map { it.enabled }, + ) + assertEquals( + listOf(true, false), + updatedMixedTab.children.map { it.enabled }, + ) + assertNotEquals( + initialMixedTab.loaderKey, + updatedMixedTab.loaderKey, + ) + } + + @Test + fun timelinePresenterFactoryUsesDynamicPresenterForSystemHomeMixedTimeline() = + runTest { + val firstSlot = homeSlot(accountId = "first") + val secondSlot = homeSlot(accountId = "second") + settingsRepository.updateTabSettingsV2 { + TabSettingsV2( + homeSlots = + listOf( + systemHomeMixedSlot(firstSlot, secondSlot), + firstSlot, + secondSlot, + ), + ) + } + + val presenter = + TimelinePresenterFactory(timelineResolver) + .create(systemHomeMixedTab()) + + assertIs(presenter) + } + + private suspend fun systemHomeMixedTab(): UiGroupTimelineTabItem { + val tab = + settingsRepository.homeTimelineTabs + .map { tabs -> tabs.first { it.isSystemHomeMixedTimeline } } + .first() + return assertIs(tab) + } + + private fun systemHomeMixedSlot(vararg children: TimelineSlot): TimelineSlot = + TimelineSlot( + id = SYSTEM_HOME_MIXED_TIMELINE_ID, + content = + TimelineSlotContent.Group( + children = children.toList(), + source = GroupSource.SystemHome, + mergePolicy = TimelineMergePolicy.TimePerPage, + ), + ) + + private fun homeSlot(accountId: String): TimelineSlot = + requireNotNull( + HomeTimelineTabItem(AccountType.Specific(MicroBlogKey(id = accountId, host = "example.com"))) + .toTimelineSlotOrNull(), + ) + private fun homeSlot( id: String, filterConfig: TimelineFilterConfig, From 4018297ce24cd315adf1543a5c891f5eecb1f996 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sun, 21 Jun 2026 02:59:31 +0900 Subject: [PATCH 6/9] rework gallery detail --- .../dev/dimension/flare/ui/AppContainer.kt | 4 +- .../ui/screen/agent/AgentChatHistoryScreen.kt | 3 +- .../ui/screen/gallery/GalleryDetailScreen.kt | 492 ++++++++++-------- .../ui/screen/status/TwitterArticleScreen.kt | 5 +- .../screen/status/action/StatusShareSheet.kt | 4 +- .../ui/screen/gallery/GalleryDetailScreen.kt | 184 +++---- .../ui/screen/settings/AgentHistoryScreen.kt | 3 +- .../screen/status/action/StatusShareSheet.kt | 4 +- .../ui/screen/xqt/TwitterArticleScreen.kt | 5 +- iosApp/flare/UI/Component/NetworkImage.swift | 25 +- .../flare/UI/Screen/GalleryDetailScreen.swift | 292 +++++------ .../dev/dimension/flare/common/Cacheable.kt | 10 + .../microblog/datasource/GalleryDataSource.kt | 43 +- .../gallery/GalleryDetailPresenter.kt | 167 ++---- .../data/datasource/pixiv/PixivDataSource.kt | 58 ++- .../data/datasource/pixiv/PixivMapper.kt | 13 - .../datasource/pixiv/PixivTimelineLoaders.kt | 29 -- 17 files changed, 657 insertions(+), 684 deletions(-) diff --git a/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt b/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt index 34ed5035c..072eecd89 100644 --- a/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt +++ b/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt @@ -37,7 +37,9 @@ fun AppContainer(afterInit: () -> Unit) { @Composable fun FlareApp(content: @Composable () -> Unit) { BindAmberSignerLauncher() - val state by producePresenter("env") { EnvironmentSettingsPresenter().invoke() } + val state by producePresenter("env") { + remember { EnvironmentSettingsPresenter() }.invoke() + } val globalAppearance = state.globalAppearance.takeSuccessOr(GlobalAppearance.Default) val timelineAppearance = state.timelineAppearance.takeSuccessOr(TimelineAppearance.Default) val originalUriHandler = LocalUriHandler.current diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/agent/AgentChatHistoryScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/agent/AgentChatHistoryScreen.kt index 6ddaf88fc..6423661fb 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/agent/AgentChatHistoryScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/agent/AgentChatHistoryScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource @@ -48,7 +49,7 @@ internal fun AgentChatHistoryScreen( modifier: Modifier = Modifier, ) { val state by producePresenter { - AgentChatHistoryPresenter().invoke() + remember { AgentChatHistoryPresenter() }.invoke() } val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() FlareScaffold( diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/gallery/GalleryDetailScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/gallery/GalleryDetailScreen.kt index 1bda9990e..fe17cb3ef 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/gallery/GalleryDetailScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/gallery/GalleryDetailScreen.kt @@ -22,13 +22,13 @@ import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan +import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SecondaryTabRow @@ -49,6 +49,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -63,7 +64,6 @@ import dev.dimension.flare.common.onEmpty import dev.dimension.flare.common.onError import dev.dimension.flare.common.onLoading import dev.dimension.flare.common.onSuccess -import dev.dimension.flare.data.datasource.microblog.ActionMenu import dev.dimension.flare.data.datasource.microblog.datasource.GalleryDetail import dev.dimension.flare.data.datasource.microblog.datasource.GalleryOrientation import dev.dimension.flare.data.model.TimelineDisplayMode @@ -88,7 +88,8 @@ import dev.dimension.flare.ui.component.status.StatusActionButton import dev.dimension.flare.ui.component.status.StatusItem import dev.dimension.flare.ui.component.status.status import dev.dimension.flare.ui.component.toImageVector -import dev.dimension.flare.ui.model.ClickEvent +import dev.dimension.flare.ui.model.ClickContext +import dev.dimension.flare.ui.model.UiIcon import dev.dimension.flare.ui.model.UiMedia import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.model.UiTimelineV2 @@ -99,7 +100,6 @@ import dev.dimension.flare.ui.presenter.gallery.GalleryDetailPresenter import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.route.Route import dev.dimension.flare.ui.theme.screenHorizontalPadding -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter @@ -116,16 +116,14 @@ internal fun GalleryDetailScreen( onBack: () -> Unit, ) { val state by producePresenter("gallery_detail_$accountType-$statusKey") { - GalleryDetailPresenter(accountType = accountType, statusKey = statusKey).invoke() + remember(accountType, statusKey) { + GalleryDetailPresenter(accountType = accountType, statusKey = statusKey) + }.invoke() } val isBigScreen = dev.dimension.flare.ui.component.platform .isBigScreen() val topAppBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() - val shareRoute = - remember(state.detail) { - state.detail.takePost()?.shareRoute() - } var showCompactInfoSheet by remember { mutableStateOf(false) } GalleryCardTimeline { FlareScaffold( @@ -133,11 +131,13 @@ internal fun GalleryDetailScreen( if (!isBigScreen) { GalleryTopAppBar( isBigScreen = false, - post = state.detail.takePost(), + detailState = state.detail, navigate = navigate, onBack = onBack, onShare = { - shareRoute?.let(navigate) + state.detail.onSuccess { detail -> + navigate(detail.shareRoute()) + } }, onExpand = { showCompactInfoSheet = true @@ -165,10 +165,11 @@ internal fun GalleryDetailScreen( navigate = navigate, onBack = onBack, onShare = { - shareRoute?.let(navigate) + state.detail.onSuccess { detail -> + navigate(detail.shareRoute()) + } }, scrollBehavior = topAppBarScrollBehavior, - onAction = state::performAction, ) } else { CompactGalleryContent( @@ -176,7 +177,6 @@ internal fun GalleryDetailScreen( comments = state.comments, recommendations = state.recommendations, navigate = navigate, - onAction = state::performAction, contentPadding = contentPadding, showInfoSheet = showCompactInfoSheet, onDismissInfoSheet = { @@ -189,7 +189,7 @@ internal fun GalleryDetailScreen( }.onError { error -> ErrorContent( error = error, - onRetry = state::refresh, + onRetry = {}, modifier = Modifier.fillMaxSize(), ) } @@ -205,7 +205,9 @@ internal fun GalleryCommentsScreen( onBack: () -> Unit, ) { val state by producePresenter("gallery_comments_$accountType-$statusKey") { - GalleryDetailPresenter(accountType = accountType, statusKey = statusKey).invoke() + remember(accountType, statusKey) { + GalleryDetailPresenter(accountType = accountType, statusKey = statusKey) + }.invoke() } val topAppBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() GalleryCardTimeline { @@ -251,7 +253,6 @@ private fun CompactGalleryContent( comments: PagingState, recommendations: PagingState, navigate: (Route) -> Unit, - onAction: (ActionMenu.Item) -> Unit, contentPadding: PaddingValues, showInfoSheet: Boolean, onDismissInfoSheet: () -> Unit, @@ -281,7 +282,6 @@ private fun CompactGalleryContent( onDismissInfoSheet() navigate(route) }, - onAction = onAction, ) } } @@ -300,25 +300,62 @@ private fun CompactGalleryContent( verticalItemSpacing = CompactTimelineSpacing, modifier = Modifier.fillMaxSize(), ) { - item(span = StaggeredGridItemSpan.FullLine) { - val configuration = LocalConfiguration.current - GalleryImages( - detail = detail, - onMediaClick = { media -> - navigate(detail.post.galleryMediaRoute(media)) - }, - modifier = - Modifier - .fillMaxWidth() - .ignoreHorizontalParentPadding(screenHorizontalPadding) - .let { - if (detail.orientation == GalleryOrientation.Horizontal) { - it.height(configuration.screenHeightDp.dp) - } else { - it - } + val images = detail.images + when (detail.orientation) { + GalleryOrientation.Vertical -> { + items( + images, + span = { + StaggeredGridItemSpan.FullLine + }, + ) { image -> + GalleryImage( + image = image, + onClick = { + navigate(detail.galleryMediaRoute(image)) }, - ) + modifier = + Modifier + .ignoreHorizontalParentPadding(screenHorizontalPadding), + ) + } + } + + GalleryOrientation.Horizontal -> { + item( + span = StaggeredGridItemSpan.FullLine, + ) { + val configuration = LocalConfiguration.current + val pagerState = + rememberPagerState( + pageCount = { images.size }, + ) + HorizontalPager( + state = pagerState, + modifier = + Modifier + .fillMaxWidth() + .ignoreHorizontalParentPadding(screenHorizontalPadding) + .height(configuration.screenHeightDp.dp), + ) { index -> + val image = images[index] + Box(Modifier.fillMaxSize()) { + NetworkImage( + model = image.url, + contentDescription = image.description, + customHeaders = image.customHeaders, + contentScale = ContentScale.Fit, + modifier = + Modifier + .fillMaxSize() + .clickable { + navigate(detail.galleryMediaRoute(image)) + }, + ) + } + } + } + } } item(span = StaggeredGridItemSpan.FullLine) { Spacer(Modifier.height(12.dp)) @@ -328,7 +365,6 @@ private fun CompactGalleryContent( comments = comments, recommendations = recommendations, navigate = navigate, - onAction = onAction, ) } } @@ -343,8 +379,8 @@ private fun BigScreenGalleryContent( onBack: () -> Unit, onShare: () -> Unit, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, - onAction: (ActionMenu.Item) -> Unit, ) { + val images = detail.images Row(Modifier.fillMaxSize()) { Box( modifier = @@ -354,7 +390,6 @@ private fun BigScreenGalleryContent( .nestedScroll(scrollBehavior.nestedScrollConnection), ) { if (detail.orientation == GalleryOrientation.Vertical) { - val images = detail.post.galleryImages() LazyColumn( modifier = Modifier.fillMaxSize(), verticalArrangement = @@ -368,23 +403,40 @@ private fun BigScreenGalleryContent( GalleryImage( image = image, onClick = { - navigate(detail.post.galleryMediaRoute(image)) + navigate(detail.galleryMediaRoute(image)) }, ) } } } else { - GalleryImages( - detail = detail, - onMediaClick = { media -> - navigate(detail.post.galleryMediaRoute(media)) - }, + val pagerState = + rememberPagerState( + pageCount = { images.size }, + ) + HorizontalPager( + state = pagerState, modifier = Modifier.fillMaxSize(), - ) + ) { index -> + val image = images[index] + Box(Modifier.fillMaxSize()) { + NetworkImage( + model = image.url, + contentDescription = image.description, + customHeaders = image.customHeaders, + contentScale = ContentScale.Fit, + modifier = + Modifier + .fillMaxSize() + .clickable { + navigate(detail.galleryMediaRoute(image)) + }, + ) + } + } } GalleryTopAppBar( isBigScreen = true, - post = detail.post, + detailState = UiState.Success(detail), navigate = navigate, onBack = onBack, onShare = onShare, @@ -393,11 +445,10 @@ private fun BigScreenGalleryContent( ) } GallerySideBar( - post = detail.post, + detail = detail, comments = comments, recommendations = recommendations, navigate = navigate, - onAction = onAction, modifier = Modifier .width(380.dp) @@ -407,65 +458,11 @@ private fun BigScreenGalleryContent( } } -@Composable -private fun GalleryImages( - detail: GalleryDetail, - onMediaClick: (UiMedia.Image) -> Unit, - modifier: Modifier = Modifier, -) { - val images = detail.post.galleryImages() - if (images.isEmpty()) { - Box( - modifier = - modifier - .height(320.dp) - .placeholder(true), - ) - return - } - when (detail.orientation) { - GalleryOrientation.Vertical -> { - Column(modifier = modifier) { - images.forEach { image -> - GalleryImage( - image = image, - onClick = { onMediaClick(image) }, - ) - } - } - } - - GalleryOrientation.Horizontal -> { - val pagerState = - rememberPagerState( - pageCount = { images.size }, - ) - HorizontalPager( - state = pagerState, - modifier = modifier, - ) { index -> - val image = images[index] - Box(Modifier.fillMaxSize()) { - NetworkImage( - model = image.url, - contentDescription = image.description, - customHeaders = image.customHeaders, - contentScale = ContentScale.Fit, - modifier = - Modifier - .fillMaxSize() - .clickable { onMediaClick(image) }, - ) - } - } - } - } -} - @Composable private fun GalleryImage( image: UiMedia.Image, onClick: () -> Unit, + modifier: Modifier = Modifier, ) { NetworkImage( model = image.url, @@ -473,20 +470,19 @@ private fun GalleryImage( customHeaders = image.customHeaders, contentScale = ContentScale.FillWidth, modifier = - Modifier - .aspectRatio(image.aspectRatio) + modifier .fillMaxWidth() + .aspectRatio(image.aspectRatio) .clickable(onClick = onClick), ) } @Composable private fun GallerySideBar( - post: UiTimelineV2.Post, + detail: GalleryDetail, comments: PagingState, recommendations: PagingState, navigate: (Route) -> Unit, - onAction: (ActionMenu.Item) -> Unit, modifier: Modifier = Modifier, ) { val pagerState = rememberPagerState(pageCount = { 3 }) @@ -524,9 +520,8 @@ private fun GallerySideBar( when (page) { 0 -> { GalleryInfoTab( - post = post, + detail = detail, navigate = navigate, - onAction = onAction, ) } @@ -560,9 +555,8 @@ private fun GallerySideBar( @Composable private fun GalleryInfoTab( - post: UiTimelineV2.Post, + detail: GalleryDetail, navigate: (Route) -> Unit, - onAction: (ActionMenu.Item) -> Unit, ) { LazyColumn( modifier = Modifier.fillMaxSize(), @@ -570,9 +564,8 @@ private fun GalleryInfoTab( ) { item { GalleryAuthorCard( - post = post, + detail = detail, navigate = navigate, - onAction = onAction, index = 0, totalCount = 2, modifier = Modifier.padding(horizontal = 16.dp), @@ -580,8 +573,7 @@ private fun GalleryInfoTab( } item { GalleryDetailInfoCard( - post = post, - onAction = onAction, + detail = detail, index = 1, totalCount = 2, modifier = Modifier.padding(horizontal = 16.dp), @@ -592,14 +584,14 @@ private fun GalleryInfoTab( @Composable private fun GalleryAuthorCard( - post: UiTimelineV2.Post, + detail: GalleryDetail, navigate: (Route) -> Unit, - onAction: (ActionMenu.Item) -> Unit, modifier: Modifier = Modifier, index: Int = 0, totalCount: Int = 0, ) { - val user = post.user + val user = detail.author + val uriHandler = LocalUriHandler.current AdaptiveCard( modifier = modifier.fillMaxWidth(), index = index, @@ -618,7 +610,7 @@ private fun GalleryAuthorCard( modifier = Modifier.clickable(enabled = user != null) { if (user != null) { - navigate(Route.Profile.User(post.accountType, user.key)) + navigate(Route.Profile.User(detail.accountType, user.key)) } }, ) @@ -627,34 +619,45 @@ private fun GalleryAuthorCard( verticalArrangement = Arrangement.spacedBy(2.dp), ) { Text( - text = post.contentWarning?.raw ?: "", + text = detail.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, ) - Text( - text = user?.handleWithoutAtAndHost.orEmpty(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - post.bookmarkAction()?.let { action -> - StatusActionButton( - icon = action.icon?.toImageVector() ?: FontAwesomeIcons.Solid.EllipsisVertical, - number = null, - color = action.color.toComposeColor(), - onClicked = { onAction(action) }, - ) + user?.let { + RichText( + text = it.name, + textStyle = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } + StatusActionButton( + icon = + if (detail.isBookmarked) { + UiIcon.Unbookmark.toImageVector() + } else { + UiIcon.Bookmark.toImageVector() + }, + number = null, + color = + if (detail.isBookmarked) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + onClicked = { + detail.onBookmark.invoke(ClickContext(uriHandler::openUri)) + }, + ) } } } @Composable private fun GalleryDetailInfoCard( - post: UiTimelineV2.Post, - onAction: (ActionMenu.Item) -> Unit, + detail: GalleryDetail, modifier: Modifier = Modifier, index: Int = 0, totalCount: Int = 0, @@ -667,19 +670,15 @@ private fun GalleryDetailInfoCard( Column( modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), ) { - GalleryMetadata( - post = post, - onAction = onAction, - ) - GalleryBody(post = post) + GalleryMetadata(detail = detail) + GalleryBody(detail = detail) } } } @Composable private fun GalleryMetadata( - post: UiTimelineV2.Post, - onAction: (ActionMenu.Item) -> Unit, + detail: GalleryDetail, modifier: Modifier = Modifier, ) { val metadataTextStyle = MaterialTheme.typography.bodySmall @@ -692,16 +691,15 @@ private fun GalleryMetadata( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { DateTimeText( - data = post.createdAt, + data = detail.createdAt, fullTime = true, style = metadataTextStyle, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f, fill = false), ) - post.countedActions().forEach { action -> - GalleryMetadataActionButton( - action = action, - onAction = onAction, + detail.matrix.forEach { matrix -> + GalleryMetadataItem( + matrix = matrix, textStyle = metadataTextStyle, ) } @@ -709,28 +707,26 @@ private fun GalleryMetadata( } @Composable -private fun GalleryMetadataActionButton( - action: ActionMenu.Item, - onAction: (ActionMenu.Item) -> Unit, +private fun GalleryMetadataItem( + matrix: GalleryDetail.Matrix, textStyle: androidx.compose.ui.text.TextStyle, modifier: Modifier = Modifier, ) { - val color = action.color.toComposeColor() + val color = MaterialTheme.colorScheme.onSurfaceVariant Row( modifier = modifier - .clickable { onAction(action) } .padding(horizontal = 4.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp), ) { FAIcon( - imageVector = action.icon?.toImageVector() ?: FontAwesomeIcons.Solid.EllipsisVertical, + imageVector = matrix.icon.toImageVector(), contentDescription = null, tint = color, modifier = Modifier.height(textStyle.fontSize.value.dp + 2.dp), ) - action.count?.humanized?.takeIf { it.isNotEmpty() }?.let { + matrix.humanizedCount.takeIf { it.isNotEmpty() }?.let { Text( text = it, style = textStyle, @@ -743,12 +739,12 @@ private fun GalleryMetadataActionButton( @Composable private fun GalleryBody( - post: UiTimelineV2.Post, + detail: GalleryDetail, modifier: Modifier = Modifier, ) { - if (post.content.isEmpty) return + val content = detail.content ?: return RichText( - text = post.content, + text = content, modifier = modifier .fillMaxWidth() @@ -761,13 +757,11 @@ private fun LazyStaggeredGridScope.galleryAfterImagesItems( comments: PagingState, recommendations: PagingState, navigate: (Route) -> Unit, - onAction: (ActionMenu.Item) -> Unit, ) { item(span = StaggeredGridItemSpan.FullLine) { GalleryAuthorCard( - post = detail.post, + detail = detail, navigate = navigate, - onAction = onAction, index = 0, totalCount = 2, modifier = @@ -777,8 +771,7 @@ private fun LazyStaggeredGridScope.galleryAfterImagesItems( } item(span = StaggeredGridItemSpan.FullLine) { GalleryDetailInfoCard( - post = detail.post, - onAction = onAction, + detail = detail, index = 1, totalCount = 2, modifier = @@ -787,8 +780,8 @@ private fun LazyStaggeredGridScope.galleryAfterImagesItems( ) } compactCommentsPreviewItems( - statusKey = detail.post.statusKey, - accountType = detail.post.accountType, + statusKey = detail.statusKey, + accountType = detail.accountType, comments = comments, navigate = navigate, ) @@ -929,7 +922,7 @@ private fun SectionTitle( @Composable private fun GalleryTopAppBar( isBigScreen: Boolean, - post: UiTimelineV2.Post?, + detailState: UiState, navigate: (Route) -> Unit, onBack: () -> Unit, onShare: () -> Unit, @@ -940,7 +933,7 @@ private fun GalleryTopAppBar( title = { if (!isBigScreen) { CompactAppBarTitle( - post = post, + detailState = detailState, navigate = navigate, ) } @@ -950,7 +943,18 @@ private fun GalleryTopAppBar( }, actions = { if (isBigScreen) { - IconButton(onClick = onShare) { + val shareEnabled = + when (detailState) { + is UiState.Success -> true + + is UiState.Loading, + is UiState.Error, + -> false + } + IconButton( + enabled = shareEnabled, + onClick = onShare, + ) { FAIcon( imageVector = FontAwesomeIcons.Solid.ShareNodes, contentDescription = "Share", @@ -963,8 +967,16 @@ private fun GalleryTopAppBar( ) } } else { + val expandEnabled = + when (detailState) { + is UiState.Success -> true + + is UiState.Loading, + is UiState.Error, + -> false + } IconButton( - enabled = post != null, + enabled = expandEnabled, onClick = onExpand, ) { FAIcon( @@ -986,10 +998,33 @@ private fun GalleryTopAppBar( @Composable private fun CompactAppBarTitle( - post: UiTimelineV2.Post?, + detailState: UiState, navigate: (Route) -> Unit, ) { - val user = post?.user + when (detailState) { + is UiState.Success -> { + CompactAppBarTitleContent( + detail = detailState.data, + navigate = navigate, + ) + } + + is UiState.Loading -> { + CompactAppBarTitleLoading() + } + + is UiState.Error -> { + Spacer(Modifier.fillMaxWidth()) + } + } +} + +@Composable +private fun CompactAppBarTitleContent( + detail: GalleryDetail, + navigate: (Route) -> Unit, +) { + val user = detail.author Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -999,26 +1034,61 @@ private fun CompactAppBarTitle( data = user?.avatar, size = 36.dp, modifier = - Modifier.clickable(enabled = post != null && user != null) { - if (post != null && user != null) { - navigate(Route.Profile.User(post.accountType, user.key)) + Modifier.clickable(enabled = user != null) { + if (user != null) { + navigate(Route.Profile.User(detail.accountType, user.key)) } }, ) Column(Modifier.weight(1f)) { Text( - text = post?.contentWarning?.raw.orEmpty(), + text = detail.title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Text( - text = user?.handleWithoutAtAndHost.orEmpty(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + user?.let { + RichText( + text = it.name, + textStyle = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun CompactAppBarTitleLoading() { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + AvatarComponent( + data = null, + size = 36.dp, + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box( + modifier = + Modifier + .fillMaxWidth(0.55f) + .height(16.dp) + .placeholder(true), + ) + Box( + modifier = + Modifier + .fillMaxWidth(0.38f) + .height(12.dp) + .placeholder(true), ) } } @@ -1070,52 +1140,16 @@ private fun GalleryLoading() { } } -private fun UiState.takePost(): UiTimelineV2.Post? = (this as? UiState.Success)?.data?.post - -private fun UiTimelineV2.Post.shareRoute(): Route.Status.ShareSheet? = - actions - .asSequence() - .filterIsInstance() - .firstNotNullOfOrNull { action -> - val text = action.text as? ActionMenu.Item.Text.Localized - if (text?.type != ActionMenu.Item.Text.Localized.Type.Share) return@firstNotNullOfOrNull null - val url = (action.clickEvent as? ClickEvent.Deeplink)?.url ?: return@firstNotNullOfOrNull null - Route.parse(url) as? Route.Status.ShareSheet - } - -private fun UiTimelineV2.Post.bookmarkAction(): ActionMenu.Item? = - actions - .asSequence() - .filterIsInstance() - .firstOrNull { action -> - val text = action.text as? ActionMenu.Item.Text.Localized - text?.type == ActionMenu.Item.Text.Localized.Type.Bookmark || - text?.type == ActionMenu.Item.Text.Localized.Type.Unbookmark - } - -private fun UiTimelineV2.Post.countedActions(): List = - actions - .asSequence() - .filterIsInstance() - .filter { it.count != null } - .toList() - -private fun UiTimelineV2.Post.galleryImages(): List = images.filterIsInstance() +private fun GalleryDetail.shareRoute(): Route.Status.ShareSheet = + Route.Status.ShareSheet( + statusKey = statusKey, + accountType = accountType, + shareUrl = url, + ) -private fun UiTimelineV2.Post.galleryMediaRoute(media: UiMedia.Image): Route.Media.RawMedia { - val medias = galleryImages().map { it as UiMedia }.toImmutableList() - return Route.Media.RawMedia( - medias = medias, - index = medias.indexOfFirst { it.url == media.url }.coerceAtLeast(0), +private fun GalleryDetail.galleryMediaRoute(media: UiMedia.Image): Route.Media.RawMedia = + Route.Media.RawMedia( + medias = images, + index = images.indexOfFirst { it.url == media.url }.coerceAtLeast(0), preview = media.previewUrl, ) -} - -@Composable -private fun ActionMenu.Item.Color?.toComposeColor(): Color = - when (this) { - ActionMenu.Item.Color.Red -> MaterialTheme.colorScheme.error - ActionMenu.Item.Color.PrimaryColor -> MaterialTheme.colorScheme.primary - ActionMenu.Item.Color.ContentColor -> LocalContentColor.current - null -> LocalContentColor.current - } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/status/TwitterArticleScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/status/TwitterArticleScreen.kt index 8bba7944f..2c01680bd 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/status/TwitterArticleScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/status/TwitterArticleScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -52,7 +53,9 @@ internal fun TwitterArticleScreen( val scrollState = rememberScrollState() val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val state by producePresenter(key = "$accountType-$tweetId-$articleId") { - TwitterArticlePresenter(accountType, tweetId, articleId).invoke() + remember(accountType, tweetId, articleId) { + TwitterArticlePresenter(accountType, tweetId, articleId) + }.invoke() } val color = if (isLightTheme()) { diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/status/action/StatusShareSheet.kt b/app/src/main/java/dev/dimension/flare/ui/screen/status/action/StatusShareSheet.kt index 5f70794f3..27f6ae820 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/status/action/StatusShareSheet.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/status/action/StatusShareSheet.kt @@ -144,7 +144,9 @@ internal fun StatusShareSheet( var previewContentHeightPx by remember { mutableIntStateOf(0) } var previewTheme by remember { mutableStateOf(SharePreviewTheme.Light) } val state by producePresenter("status_share_sheet_${statusKey}_$shareUrl") { - StatusPresenter(accountType = accountType, statusKey = statusKey).invoke() + remember(accountType, statusKey) { + StatusPresenter(accountType = accountType, statusKey = statusKey) + }.invoke() } val status = state.status.takeSuccess() val shareCaptureWidthPx = with(density) { ShareCaptureWidth.roundToPx() } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/gallery/GalleryDetailScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/gallery/GalleryDetailScreen.kt index 8e2cd694b..d7a01abe4 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/gallery/GalleryDetailScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/gallery/GalleryDetailScreen.kt @@ -36,23 +36,18 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.paging.LoadState -import compose.icons.FontAwesomeIcons -import compose.icons.fontawesomeicons.Solid -import compose.icons.fontawesomeicons.solid.EllipsisVertical import dev.dimension.flare.LocalWindowPadding -import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.common.PagingState import dev.dimension.flare.common.isRefreshing import dev.dimension.flare.common.onEmpty import dev.dimension.flare.common.onError import dev.dimension.flare.common.onLoading import dev.dimension.flare.common.onSuccess -import dev.dimension.flare.data.datasource.microblog.ActionMenu import dev.dimension.flare.data.datasource.microblog.datasource.GalleryDetail import dev.dimension.flare.data.datasource.microblog.datasource.GalleryOrientation import dev.dimension.flare.data.model.TimelineDisplayMode @@ -74,6 +69,8 @@ import dev.dimension.flare.ui.component.status.StatusActionButton import dev.dimension.flare.ui.component.status.StatusItem import dev.dimension.flare.ui.component.status.status import dev.dimension.flare.ui.component.toImageVector +import dev.dimension.flare.ui.model.ClickContext +import dev.dimension.flare.ui.model.UiIcon import dev.dimension.flare.ui.model.UiMedia import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.model.onError @@ -103,7 +100,9 @@ internal fun GalleryDetailScreen( navigate: (Route) -> Unit, ) { val state by producePresenter("desktop_gallery_detail_$accountType-$statusKey") { - GalleryDetailPresenter(accountType = accountType, statusKey = statusKey).invoke() + remember(accountType, statusKey) { + GalleryDetailPresenter(accountType = accountType, statusKey = statusKey) + }.invoke() } GalleryCardTimeline { state.detail @@ -115,7 +114,6 @@ internal fun GalleryDetailScreen( comments = state.comments, recommendations = state.recommendations, navigate = navigate, - onAction = state::performAction, ) } else { BigScreenGalleryContent( @@ -123,7 +121,6 @@ internal fun GalleryDetailScreen( comments = state.comments, recommendations = state.recommendations, navigate = navigate, - onAction = state::performAction, ) } } @@ -132,7 +129,7 @@ internal fun GalleryDetailScreen( }.onError { error -> ErrorContent( error = error, - onRetry = state::refresh, + onRetry = {}, modifier = Modifier.fillMaxSize(), ) } @@ -145,10 +142,11 @@ internal fun GalleryCommentsScreen( accountType: AccountType, ) { val state by producePresenter("desktop_gallery_comments_$accountType-$statusKey") { - GalleryDetailPresenter(accountType = accountType, statusKey = statusKey).invoke() + remember(accountType, statusKey) { + GalleryDetailPresenter(accountType = accountType, statusKey = statusKey) + }.invoke() } val listState = rememberLazyStaggeredGridState() - RegisterTabCallback(listState, onRefresh = state::refresh) GalleryCardTimeline { Box(Modifier.fillMaxSize()) { FlareScrollBar(listState) { @@ -190,7 +188,6 @@ private fun CompactGalleryContent( comments: PagingState, recommendations: PagingState, navigate: (Route) -> Unit, - onAction: (ActionMenu.Item) -> Unit, ) { val gridState = rememberLazyStaggeredGridState() FlareScrollBar(gridState) { @@ -212,7 +209,7 @@ private fun CompactGalleryContent( GalleryImages( detail = detail, onMediaClick = { media -> - navigate(detail.post.statusMediaRoute(media)) + navigate(detail.statusMediaRoute(media)) }, modifier = Modifier @@ -234,7 +231,6 @@ private fun CompactGalleryContent( comments = comments, recommendations = recommendations, navigate = navigate, - onAction = onAction, ) } } @@ -246,13 +242,12 @@ private fun BigScreenGalleryContent( comments: PagingState, recommendations: PagingState, navigate: (Route) -> Unit, - onAction: (ActionMenu.Item) -> Unit, ) { Row(Modifier.fillMaxSize()) { GalleryImagePane( detail = detail, onMediaClick = { media -> - navigate(detail.post.statusMediaRoute(media)) + navigate(detail.statusMediaRoute(media)) }, modifier = Modifier @@ -260,11 +255,10 @@ private fun BigScreenGalleryContent( .fillMaxHeight(), ) GallerySideBar( - post = detail.post, + detail = detail, comments = comments, recommendations = recommendations, navigate = navigate, - onAction = onAction, modifier = Modifier .width(SideBarWidth) @@ -279,7 +273,7 @@ private fun GalleryImagePane( onMediaClick: (UiMedia.Image) -> Unit, modifier: Modifier = Modifier, ) { - val images = detail.post.galleryImages() + val images = detail.images Box( modifier = modifier @@ -349,7 +343,7 @@ private fun GalleryImages( onMediaClick: (UiMedia.Image) -> Unit, modifier: Modifier = Modifier, ) { - val images = detail.post.galleryImages() + val images = detail.images if (images.isEmpty()) { Box( modifier = @@ -416,11 +410,10 @@ private fun GalleryImage( @Composable private fun GallerySideBar( - post: UiTimelineV2.Post, + detail: GalleryDetail, comments: PagingState, recommendations: PagingState, navigate: (Route) -> Unit, - onAction: (ActionMenu.Item) -> Unit, modifier: Modifier = Modifier, ) { var selectedTab by remember { mutableIntStateOf(0) } @@ -458,9 +451,8 @@ private fun GallerySideBar( when (selectedTab) { 0 -> { GalleryInfoTab( - post = post, + detail = detail, navigate = navigate, - onAction = onAction, modifier = Modifier.weight(1f), ) } @@ -519,9 +511,8 @@ private fun GallerySideBarTab( @Composable private fun GalleryInfoTab( - post: UiTimelineV2.Post, + detail: GalleryDetail, navigate: (Route) -> Unit, - onAction: (ActionMenu.Item) -> Unit, modifier: Modifier = Modifier, ) { val listState = rememberLazyListState() @@ -533,9 +524,8 @@ private fun GalleryInfoTab( ) { item { GalleryAuthorCard( - post = post, + detail = detail, navigate = navigate, - onAction = onAction, index = 0, totalCount = 2, modifier = Modifier.padding(horizontal = 16.dp), @@ -543,8 +533,7 @@ private fun GalleryInfoTab( } item { GalleryDetailInfoCard( - post = post, - onAction = onAction, + detail = detail, index = 1, totalCount = 2, modifier = Modifier.padding(horizontal = 16.dp), @@ -556,14 +545,14 @@ private fun GalleryInfoTab( @Composable private fun GalleryAuthorCard( - post: UiTimelineV2.Post, + detail: GalleryDetail, navigate: (Route) -> Unit, - onAction: (ActionMenu.Item) -> Unit, index: Int = 0, totalCount: Int = 0, modifier: Modifier = Modifier, ) { - val user = post.user + val user = detail.author + val uriHandler = LocalUriHandler.current AdaptiveCard( modifier = modifier.fillMaxWidth(), index = index, @@ -582,7 +571,7 @@ private fun GalleryAuthorCard( modifier = Modifier.clickable(enabled = user != null) { if (user != null) { - navigate(Route.Profile(post.accountType, user.key)) + navigate(Route.Profile(detail.accountType, user.key)) } }, ) @@ -591,35 +580,46 @@ private fun GalleryAuthorCard( verticalArrangement = Arrangement.spacedBy(2.dp), ) { Text( - text = post.contentWarning?.raw.orEmpty(), + text = detail.title, style = FluentTheme.typography.bodyStrong, maxLines = Int.MAX_VALUE, overflow = TextOverflow.Clip, ) - Text( - text = user?.handleWithoutAtAndHost.orEmpty(), - style = FluentTheme.typography.caption, - color = FluentTheme.colors.text.text.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - post.bookmarkAction()?.let { action -> - StatusActionButton( - icon = action.icon?.toImageVector() ?: FontAwesomeIcons.Solid.EllipsisVertical, - number = null, - color = action.color.toComposeColor(), - onClicked = { onAction(action) }, - ) + user?.let { + RichText( + text = it.name, + textStyle = FluentTheme.typography.caption, + color = FluentTheme.colors.text.text.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } + StatusActionButton( + icon = + if (detail.isBookmarked) { + UiIcon.Unbookmark.toImageVector() + } else { + UiIcon.Bookmark.toImageVector() + }, + number = null, + color = + if (detail.isBookmarked) { + FluentTheme.colors.text.accent.primary + } else { + FluentTheme.colors.text.text.secondary + }, + onClicked = { + detail.onBookmark.invoke(ClickContext(uriHandler::openUri)) + }, + ) } } } @Composable private fun GalleryDetailInfoCard( - post: UiTimelineV2.Post, - onAction: (ActionMenu.Item) -> Unit, + detail: GalleryDetail, index: Int = 0, totalCount: Int = 0, modifier: Modifier = Modifier, @@ -632,19 +632,15 @@ private fun GalleryDetailInfoCard( Column( modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), ) { - GalleryMetadata( - post = post, - onAction = onAction, - ) - GalleryBody(post = post) + GalleryMetadata(detail = detail) + GalleryBody(detail = detail) } } } @Composable private fun GalleryMetadata( - post: UiTimelineV2.Post, - onAction: (ActionMenu.Item) -> Unit, + detail: GalleryDetail, modifier: Modifier = Modifier, ) { Row( @@ -656,43 +652,38 @@ private fun GalleryMetadata( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { DateTimeText( - data = post.createdAt, + data = detail.createdAt, fullTime = true, style = FluentTheme.typography.caption, color = FluentTheme.colors.text.text.secondary, modifier = Modifier.weight(1f, fill = false), ) - post.countedActions().forEach { action -> - GalleryMetadataActionButton( - action = action, - onAction = onAction, - ) + detail.matrix.forEach { matrix -> + GalleryMetadataItem(matrix = matrix) } } } @Composable -private fun GalleryMetadataActionButton( - action: ActionMenu.Item, - onAction: (ActionMenu.Item) -> Unit, +private fun GalleryMetadataItem( + matrix: GalleryDetail.Matrix, modifier: Modifier = Modifier, ) { - val color = action.color.toComposeColor() + val color = FluentTheme.colors.text.text.secondary Row( modifier = modifier - .clickable { onAction(action) } .padding(horizontal = 4.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp), ) { FAIcon( - imageVector = action.icon?.toImageVector() ?: FontAwesomeIcons.Solid.EllipsisVertical, + imageVector = matrix.icon.toImageVector(), contentDescription = null, tint = color, modifier = Modifier.size(FluentTheme.typography.caption.fontSize.value.dp + 2.dp), ) - action.count?.humanized?.takeIf { it.isNotEmpty() }?.let { + matrix.humanizedCount.takeIf { it.isNotEmpty() }?.let { Text( text = it, style = FluentTheme.typography.caption, @@ -705,12 +696,12 @@ private fun GalleryMetadataActionButton( @Composable private fun GalleryBody( - post: UiTimelineV2.Post, + detail: GalleryDetail, modifier: Modifier = Modifier, ) { - if (post.content.isEmpty) return + val content = detail.content ?: return RichText( - text = post.content, + text = content, textStyle = FluentTheme.typography.body, modifier = modifier @@ -724,13 +715,11 @@ private fun LazyStaggeredGridScope.galleryAfterImagesItems( comments: PagingState, recommendations: PagingState, navigate: (Route) -> Unit, - onAction: (ActionMenu.Item) -> Unit, ) { item(span = StaggeredGridItemSpan.FullLine) { GalleryAuthorCard( - post = detail.post, + detail = detail, navigate = navigate, - onAction = onAction, index = 0, totalCount = 2, modifier = Modifier.fillMaxWidth(), @@ -738,16 +727,15 @@ private fun LazyStaggeredGridScope.galleryAfterImagesItems( } item(span = StaggeredGridItemSpan.FullLine) { GalleryDetailInfoCard( - post = detail.post, - onAction = onAction, + detail = detail, index = 1, totalCount = 2, modifier = Modifier.fillMaxWidth(), ) } compactCommentsPreviewItems( - statusKey = detail.post.statusKey, - accountType = detail.post.accountType, + statusKey = detail.statusKey, + accountType = detail.accountType, comments = comments, navigate = navigate, ) @@ -896,26 +884,7 @@ private fun GalleryLoading() { } } -private fun UiTimelineV2.Post.bookmarkAction(): ActionMenu.Item? = - actions - .asSequence() - .filterIsInstance() - .firstOrNull { action -> - val text = action.text as? ActionMenu.Item.Text.Localized - text?.type == ActionMenu.Item.Text.Localized.Type.Bookmark || - text?.type == ActionMenu.Item.Text.Localized.Type.Unbookmark - } - -private fun UiTimelineV2.Post.countedActions(): List = - actions - .asSequence() - .filterIsInstance() - .filter { it.count != null } - .toList() - -private fun UiTimelineV2.Post.galleryImages(): List = images.filterIsInstance() - -private fun UiTimelineV2.Post.statusMediaRoute(media: UiMedia): Route.StatusMedia = +private fun GalleryDetail.statusMediaRoute(media: UiMedia): Route.StatusMedia = Route.StatusMedia( statusKey = statusKey, accountType = accountType, @@ -928,12 +897,3 @@ private fun UiTimelineV2.Post.statusMediaRoute(media: UiMedia): Route.StatusMedi is UiMedia.Audio -> null }, ) - -@Composable -private fun ActionMenu.Item.Color?.toComposeColor(): Color = - when (this) { - ActionMenu.Item.Color.Red -> FluentTheme.colors.system.critical - ActionMenu.Item.Color.PrimaryColor -> FluentTheme.colors.text.accent.primary - ActionMenu.Item.Color.ContentColor -> FluentTheme.colors.text.text.primary - null -> FluentTheme.colors.text.text.primary - } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/AgentHistoryScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/AgentHistoryScreen.kt index 65dc846c1..de06632e0 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/AgentHistoryScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/AgentHistoryScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -47,7 +48,7 @@ internal fun AgentHistoryScreen( onNewConversationClick: () -> Unit, ) { val state by producePresenter { - AgentChatHistoryPresenter().invoke() + remember { AgentChatHistoryPresenter() }.invoke() } val listState = rememberLazyListState() Column( diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/StatusShareSheet.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/StatusShareSheet.kt index 3aca1076d..2daab80d0 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/StatusShareSheet.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/StatusShareSheet.kt @@ -100,7 +100,9 @@ internal fun StatusShareSheet( var previewTheme by remember { mutableStateOf(SharePreviewTheme.Light) } val state by producePresenter("DesktopStatusShareSheet_${accountType}_$statusKey") { - StatusPresenter(accountType = accountType, statusKey = statusKey).invoke() + remember(accountType, statusKey) { + StatusPresenter(accountType = accountType, statusKey = statusKey) + }.invoke() } FluentDialog( visible = true, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/xqt/TwitterArticleScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/xqt/TwitterArticleScreen.kt index 565103e44..c32281bcc 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/xqt/TwitterArticleScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/xqt/TwitterArticleScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler @@ -44,7 +45,9 @@ internal fun TwitterArticleScreen( ) { val uriHandler = LocalUriHandler.current val state by producePresenter(key = "$accountType-$tweetId-$articleId") { - TwitterArticlePresenter(accountType, tweetId, articleId).invoke() + remember(accountType, tweetId, articleId) { + TwitterArticlePresenter(accountType, tweetId, articleId) + }.invoke() } val scrollState = rememberScrollState() Box( diff --git a/iosApp/flare/UI/Component/NetworkImage.swift b/iosApp/flare/UI/Component/NetworkImage.swift index 214cebfdb..f248c2217 100644 --- a/iosApp/flare/UI/Component/NetworkImage.swift +++ b/iosApp/flare/UI/Component/NetworkImage.swift @@ -5,6 +5,7 @@ struct NetworkImage: View { let data: URL? let placeholder: URL? let customHeader: [String: String]? + let contentMode: SwiftUI.ContentMode var body: some View { if data?.absoluteString.hasSuffix(".gif") == true { KFAnimatedImage(data) @@ -32,7 +33,7 @@ struct NetworkImage: View { // image // }) // .resizable() - .scaledToFill() + .aspectRatio(contentMode: contentMode) } else { KFImage(data) .resizable() @@ -60,25 +61,25 @@ struct NetworkImage: View { // image // }) // .resizable() - .scaledToFill() + .aspectRatio(contentMode: contentMode) } } } extension NetworkImage { - init(data: String?, customHeader: [String: String]? = nil) { - self.init(data: data.flatMap(URL.init(string:)), placeholder: nil, customHeader: customHeader) + init(data: String?, customHeader: [String: String]? = nil, contentMode: SwiftUI.ContentMode = .fill) { + self.init(data: data.flatMap(URL.init(string:)), placeholder: nil, customHeader: customHeader, contentMode: contentMode) } - init(data: String, customHeader: [String: String]? = nil) { - self.init(data: .init(string: data), placeholder: nil, customHeader: customHeader) + init(data: String, customHeader: [String: String]? = nil, contentMode: SwiftUI.ContentMode = .fill) { + self.init(data: .init(string: data), placeholder: nil, customHeader: customHeader, contentMode: contentMode) } - init(data: String, placeholder: String, customHeader: [String: String]? = nil) { - self.init(data: .init(string: data), placeholder: .init(string: placeholder), customHeader: customHeader) + init(data: String, placeholder: String, customHeader: [String: String]? = nil, contentMode: SwiftUI.ContentMode = .fill) { + self.init(data: .init(string: data), placeholder: .init(string: placeholder), customHeader: customHeader, contentMode: contentMode) } - init(data: URL?) { - self.init(data: data, placeholder: nil, customHeader: nil) + init(data: URL?, contentMode: SwiftUI.ContentMode = .fill) { + self.init(data: data, placeholder: nil, customHeader: nil, contentMode: contentMode) } - init(data: URL?, customHeader: [String: String]?) { - self.init(data: data, placeholder: nil, customHeader: customHeader) + init(data: URL?, customHeader: [String: String]?, contentMode: SwiftUI.ContentMode = .fill) { + self.init(data: data, placeholder: nil, customHeader: customHeader, contentMode: contentMode) } } diff --git a/iosApp/flare/UI/Screen/GalleryDetailScreen.swift b/iosApp/flare/UI/Screen/GalleryDetailScreen.swift index 2e877eacc..cc68946b8 100644 --- a/iosApp/flare/UI/Screen/GalleryDetailScreen.swift +++ b/iosApp/flare/UI/Screen/GalleryDetailScreen.swift @@ -34,7 +34,6 @@ struct GalleryDetailScreen: View { } } errorContent: { error in ListErrorView(error: error) { - presenter.state.refresh() } .frame(maxWidth: .infinity, maxHeight: .infinity) } loadingContent: { @@ -47,27 +46,49 @@ struct GalleryDetailScreen: View { .toolbar { if !isBigScreen { ToolbarItem(placement: .principal) { - GalleryCompactToolbarTitle(post: presenter.state.detail.galleryPost) { + GalleryCompactToolbarTitle(detailState: presenter.state.detail) { navigateToProfile($0) } } ToolbarItemGroup(placement: .primaryAction) { Button { - presenter.state.detail.galleryPost?.shareAction?.onClicked( - ClickContext(launcher: AppleUriLauncher(openUrl: openURL)) - ) + switch onEnum(of: presenter.state.detail) { + case .success(let success): + navigateToShare(success.data) + case .loading, .error: + break + } } label: { Image("fa-share-nodes") } - .disabled(presenter.state.detail.galleryPost?.shareAction == nil) + .disabled({ + switch onEnum(of: presenter.state.detail) { + case .success: + return false + case .loading, .error: + return true + } + }()) Button { - showInfoSheet = true + switch onEnum(of: presenter.state.detail) { + case .success: + showInfoSheet = true + case .loading, .error: + break + } } label: { Image("fa-chevron-down") } - .disabled(presenter.state.detail.galleryPost == nil) + .disabled({ + switch onEnum(of: presenter.state.detail) { + case .success: + return false + case .loading, .error: + return true + } + }()) } } } @@ -78,17 +99,16 @@ struct GalleryDetailScreen: View { LazyVStack(spacing: 2) { GalleryImagesView( detail: detail, - openMedia: { image in navigateToMedia(post: detail.post, media: image) } + openMedia: { image in navigateToMedia(detail: detail, media: image) } ) .padding(.bottom, 10) GalleryAfterImagesContent( - post: detail.post, + detail: detail, comments: presenter.state.comments, recommendations: presenter.state.recommendations, commentLimit: 3, onProfile: navigateToProfile, - onAction: { presenter.state.performAction(action: $0) }, onViewMoreComments: { onNavigate(.galleryComments(accountType, statusKey)) }, @@ -98,20 +118,16 @@ struct GalleryDetailScreen: View { } .padding(.bottom, 24) } - .refreshable { - presenter.state.refresh() - } .sheet(isPresented: $showInfoSheet) { NavigationStack { ScrollView { LazyVStack(spacing: 2) { GalleryAfterImagesContent( - post: detail.post, + detail: detail, comments: presenter.state.comments, recommendations: presenter.state.recommendations, commentLimit: 3, onProfile: navigateToProfile, - onAction: { presenter.state.performAction(action: $0) }, onViewMoreComments: { showInfoSheet = false onNavigate(.galleryComments(accountType, statusKey)) @@ -134,7 +150,7 @@ struct GalleryDetailScreen: View { HStack(spacing: 0) { GalleryBigScreenImagePane( detail: detail, - onOpenMedia: { image in navigateToMedia(post: detail.post, media: image) } + onOpenMedia: { image in navigateToMedia(detail: detail, media: image) } ) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -142,20 +158,16 @@ struct GalleryDetailScreen: View { GallerySideBar( selectedTab: $selectedTab, - post: detail.post, + detail: detail, comments: presenter.state.comments, recommendations: presenter.state.recommendations, onProfile: navigateToProfile, - onAction: { presenter.state.performAction(action: $0) } ) .frame(width: sidebarWidth(for: proxy.size.width)) .frame(maxHeight: .infinity) .background(Color(.systemGroupedBackground)) } } - .refreshable { - presenter.state.refresh() - } } private func sidebarWidth(for width: CGFloat) -> CGFloat { @@ -169,8 +181,18 @@ struct GalleryDetailScreen: View { } } + private func navigateToShare(_ detail: GalleryDetail) { + onNavigate(.statusShareSheet(detail.accountType, detail.statusKey, detail.url, nil, nil)) + } + + private func navigateToMedia(detail: GalleryDetail, media: UiMedia) { + let medias = detail.images.map { $0 as any UiMedia } + let index = medias.firstIndex { $0.url == media.url } ?? 0 + onNavigate(.mediaRaw(medias, index, media.mediaPreviewURL)) + } + private func navigateToMedia(post: UiTimelineV2.Post, media: UiMedia) { - let medias = post.galleryImages.map { $0 as any UiMedia } + let medias = post.galleryImagesForRawMedia.map { $0 as any UiMedia } let index = medias.firstIndex { $0.url == media.url } ?? 0 onNavigate(.mediaRaw(medias, index, media.mediaPreviewURL)) } @@ -197,7 +219,7 @@ private struct GalleryImagesView: View { let openMedia: (UiMediaImage) -> Void private var images: [UiMediaImage] { - detail.post.galleryImages + detail.images.map { $0 } } var body: some View { @@ -225,11 +247,9 @@ private struct GalleryImagesView: View { LazyVStack(spacing: 0) { ForEach(0.. Void private var images: [UiMediaImage] { - detail.post.galleryImages + detail.images.map { $0 } } var body: some View { @@ -277,11 +297,9 @@ private struct GalleryBigScreenImagePane: View { LazyVStack(spacing: 0) { ForEach(0.. let recommendations: PagingState let commentLimit: Int? let onProfile: (UiProfile) -> Void - let onAction: (ActionMenu.Item) -> Void let onViewMoreComments: () -> Void let onOpenMedia: (UiTimelineV2.Post, UiMedia) -> Void var body: some View { VStack(spacing: 2) { - GalleryAuthorCard(post: post, onProfile: onProfile, onAction: onAction) - GalleryInfoCard(post: post, onAction: onAction) + GalleryAuthorCard(detail: detail, onProfile: onProfile) + GalleryInfoCard(detail: detail) } GalleryCommentsPreview( @@ -330,14 +347,14 @@ private struct GalleryAfterImagesContent: View { } private struct GalleryAuthorCard: View { - let post: UiTimelineV2.Post + @Environment(\.openURL) private var openURL + let detail: GalleryDetail let onProfile: (UiProfile) -> Void - let onAction: (ActionMenu.Item) -> Void var body: some View { ListCardView(index: 0, totalCount: 2) { HStack(spacing: 12) { - if let user = post.user { + if let user = detail.author { AvatarView(data: user.avatar?.url, customHeader: user.avatar?.customHeaders) .frame(width: 44, height: 44) .onTapGesture { @@ -350,27 +367,25 @@ private struct GalleryAuthorCard: View { } VStack(alignment: .leading, spacing: 2) { - Text(post.galleryTitle) + Text(detail.title) .font(.headline) .fontWeight(.semibold) .fixedSize(horizontal: false, vertical: true) - if let user = post.user { - Text(user.handle.canonical) + if let user = detail.author { + RichText(text: user.name) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } } Spacer(minLength: 8) - if let action = post.bookmarkAction { - Button { - onAction(action) - } label: { - StatusActionIcon(icon: action.icon) - } - .foregroundStyle(action.color?.swiftColor ?? .primary) - .buttonStyle(.plain) + Button { + detail.onBookmark(ClickContext(launcher: AppleUriLauncher(openUrl: openURL))) + } label: { + Image(detail.isBookmarked ? "fa-bookmark.fill" : "fa-bookmark") } + .foregroundStyle(detail.isBookmarked ? Color.accentColor : Color.secondary) + .buttonStyle(.plain) } .padding(.horizontal, 12) .padding(.vertical, 10) @@ -379,15 +394,14 @@ private struct GalleryAuthorCard: View { } private struct GalleryInfoCard: View { - let post: UiTimelineV2.Post - let onAction: (ActionMenu.Item) -> Void + let detail: GalleryDetail var body: some View { ListCardView(index: 1, totalCount: 2) { VStack(alignment: .leading, spacing: 8) { - GalleryMetadataRow(post: post, onAction: onAction) - if !post.content.isEmpty { - RichText(text: post.content) + GalleryMetadataRow(detail: detail) + if let content = detail.content { + RichText(text: content) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) } @@ -399,29 +413,23 @@ private struct GalleryInfoCard: View { } private struct GalleryMetadataRow: View { - let post: UiTimelineV2.Post - let onAction: (ActionMenu.Item) -> Void + let detail: GalleryDetail var body: some View { HStack(alignment: .firstTextBaseline, spacing: 8) { - DateTimeText(data: post.createdAt, fullTime: true) + DateTimeText(data: detail.createdAt, fullTime: true) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) - ForEach(Array(post.countedActions.enumerated()), id: \.offset) { _, action in - Button { - onAction(action) - } label: { - HStack(alignment: .firstTextBaseline, spacing: 2) { - StatusActionIcon(icon: action.icon) - if let count = action.count?.humanized, !count.isEmpty { - Text(count) - } + ForEach(Array(detail.matrix.enumerated()), id: \.offset) { _, item in + HStack(alignment: .firstTextBaseline, spacing: 2) { + StatusActionIcon(icon: item.icon) + if !item.humanizedCount.isEmpty { + Text(item.humanizedCount) } - .font(.caption) } - .foregroundStyle(action.color?.swiftColor ?? .secondary) - .buttonStyle(.plain) + .font(.caption) + .foregroundStyle(.secondary) } Spacer(minLength: 0) } @@ -593,7 +601,7 @@ private struct GalleryRecommendationTile: View { AvatarView(data: avatar.url, customHeader: avatar.customHeaders) .frame(width: 24, height: 24) } - Text(post.user?.name.raw ?? post.galleryTitle) + Text(post.user?.name.raw ?? post.contentWarning?.raw ?? "") .font(.caption) .lineLimit(1) } @@ -625,11 +633,10 @@ private struct GalleryRecommendationTile: View { private struct GallerySideBar: View { @Binding var selectedTab: GallerySideTab - let post: UiTimelineV2.Post + let detail: GalleryDetail let comments: PagingState let recommendations: PagingState let onProfile: (UiProfile) -> Void - let onAction: (ActionMenu.Item) -> Void var body: some View { VStack(spacing: 0) { @@ -646,8 +653,8 @@ private struct GallerySideBar: View { case .info: ScrollView { LazyVStack(spacing: 2) { - GalleryAuthorCard(post: post, onProfile: onProfile, onAction: onAction) - GalleryInfoCard(post: post, onAction: onAction) + GalleryAuthorCard(detail: detail, onProfile: onProfile) + GalleryInfoCard(detail: detail) } .padding(.horizontal, 16) .padding(.top, 8) @@ -656,7 +663,7 @@ private struct GallerySideBar: View { TimelinePagingContent( data: comments, detailStatusKey: nil, - key: "gallery_comments_\(post.statusKey.description())", + key: "gallery_comments_\(detail.statusKey.description())", suppressInitialRefreshIndicator: true ) case .recommend: @@ -667,30 +674,61 @@ private struct GallerySideBar: View { } private struct GalleryCompactToolbarTitle: View { - let post: UiTimelineV2.Post? + let detailState: UiState let onProfile: (UiProfile) -> Void var body: some View { - HStack(spacing: 10) { - if let user = post?.user { - AvatarView(data: user.avatar?.url, customHeader: user.avatar?.customHeaders) - .frame(width: 32, height: 32) - .onTapGesture { - onProfile(user) + switch onEnum(of: detailState) { + case .success(let success): + let detail = success.data + let user = detail.author + HStack(spacing: 10) { + if let user { + AvatarView(data: user.avatar?.url, customHeader: user.avatar?.customHeaders) + .frame(width: 32, height: 32) + .onTapGesture { + onProfile(user) + } + } + VStack(alignment: .leading, spacing: 1) { + Text(detail.title) + .font(.subheadline) + .fontWeight(.semibold) + .lineLimit(1) + if let user { + RichText(text: user.name) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) } + } + .frame(maxWidth: 220, alignment: .leading) } + case .loading: + GalleryCompactToolbarTitleLoading() + case .error: + EmptyView() + } + } +} + +private struct GalleryCompactToolbarTitleLoading: View { + var body: some View { + HStack(spacing: 10) { + Circle() + .fill(.placeholder) + .frame(width: 32, height: 32) VStack(alignment: .leading, spacing: 1) { - Text(post?.galleryTitle ?? "") - .font(.subheadline) - .fontWeight(.semibold) - .lineLimit(1) - Text(post?.user?.handle.canonical ?? "") - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) + RoundedRectangle(cornerRadius: 3) + .fill(.placeholder) + .frame(width: 140, height: 14) + RoundedRectangle(cornerRadius: 3) + .fill(.placeholder) + .frame(width: 84, height: 10) } .frame(maxWidth: 220, alignment: .leading) } + .redacted(reason: .placeholder) } } @@ -755,17 +793,8 @@ struct GalleryCommentsScreen: View { } } -private extension UiState where T == GalleryDetail { - var galleryPost: UiTimelineV2.Post? { - if case .success(let success) = onEnum(of: self) { - return success.data.post - } - return nil - } -} - private extension UiTimelineV2.Post { - var galleryImages: [UiMediaImage] { + var galleryImagesForRawMedia: [UiMediaImage] { images.compactMap { media in if case .image(let image) = onEnum(of: media) { return image @@ -773,69 +802,6 @@ private extension UiTimelineV2.Post { return nil } } - - var galleryTitle: String { - contentWarning?.raw ?? "" - } - - var bookmarkAction: ActionMenu.Item? { - for action in actions { - if case .item(let item) = onEnum(of: action), item.isBookmarkAction { - return item - } - } - return nil - } - - var shareAction: ActionMenu.Item? { - for action in actions { - if case .item(let item) = onEnum(of: action), item.isShareAction { - return item - } - } - return nil - } - - var countedActions: [ActionMenu.Item] { - actions.compactMap { action in - guard case .item(let item) = onEnum(of: action), item.count != nil else { - return nil - } - return item - } - } - - func statusMediaRoute(media: UiMedia) -> DeeplinkRoute.MediaStatusMedia { - let mediaIndex = images.firstIndex { $0.url == media.url } ?? 0 - let preview: String? = switch onEnum(of: media) { - case .image(let image): image.previewUrl - case .video(let video): video.thumbnailUrl - case .gif(let gif): gif.previewUrl - case .audio: nil - } - return DeeplinkRoute.MediaStatusMedia( - statusKey: statusKey, - accountType: accountType, - index: Int32(mediaIndex), - preview: preview - ) - } -} - -private extension ActionMenu.Item { - var isBookmarkAction: Bool { - guard let text, case .localized(let localized) = onEnum(of: text) else { - return false - } - return localized.type == .bookmark || localized.type == .unbookmark - } - - var isShareAction: Bool { - guard let text, case .localized(let localized) = onEnum(of: text) else { - return false - } - return localized.type == .share - } } private extension PagingStateSuccess { diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/common/Cacheable.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/common/Cacheable.kt index e82d4d43a..b7b1abd10 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/common/Cacheable.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/common/Cacheable.kt @@ -107,6 +107,16 @@ public sealed class CacheData( public fun refresh() { refreshFlow.value++ } + + public fun map(transform: (T) -> R): CacheData = + Cacheable( + fetchSource = fetchSource, + cacheSource = { + cacheSource.invoke().map { + transform.invoke(it) + } + }, + ) } @HiddenFromObjC diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/datasource/GalleryDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/datasource/GalleryDataSource.kt index b3294e624..4b3d9fed1 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/datasource/GalleryDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/datasource/GalleryDataSource.kt @@ -1,15 +1,27 @@ package dev.dimension.flare.data.datasource.microblog.datasource import androidx.compose.runtime.Immutable +import dev.dimension.flare.common.Cacheable +import dev.dimension.flare.common.SerializableImmutableList import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader +import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.humanizer.Formatter.humanize +import dev.dimension.flare.ui.model.ClickContext +import dev.dimension.flare.ui.model.ClickEvent +import dev.dimension.flare.ui.model.UiIcon +import dev.dimension.flare.ui.model.UiMedia +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.onClicked +import dev.dimension.flare.ui.render.UiDateTime +import dev.dimension.flare.ui.render.UiRichText import kotlinx.serialization.Serializable import kotlin.native.HiddenFromObjC @HiddenFromObjC public interface GalleryDataSource { - public fun galleryDetail(statusKey: MicroBlogKey): RemoteLoader + public fun galleryDetail(statusKey: MicroBlogKey): Cacheable public fun galleryComments(statusKey: MicroBlogKey): RemoteLoader @@ -19,9 +31,34 @@ public interface GalleryDataSource { @Serializable @Immutable public data class GalleryDetail( - val post: UiTimelineV2.Post, val orientation: GalleryOrientation, -) + val statusKey: MicroBlogKey, + val accountType: AccountType, + val url: String, + val images: SerializableImmutableList, + val title: String, + val author: UiProfile?, + val createdAt: UiDateTime, + val content: UiRichText?, + val isBookmarked: Boolean, + val bookmarkAction: ClickEvent, + val matrix: SerializableImmutableList, +) { + val onBookmark: ClickContext.() -> Unit by lazy { + bookmarkAction.onClicked + } + + @Serializable + @Immutable + public data class Matrix( + public val icon: UiIcon, + public val count: Long, + ) { + public val humanizedCount: String by lazy { + count.humanize() + } + } +} @Serializable @Immutable diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/gallery/GalleryDetailPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/gallery/GalleryDetailPresenter.kt index db4a87869..24d0a44e8 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/gallery/GalleryDetailPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/gallery/GalleryDetailPresenter.kt @@ -2,42 +2,34 @@ package dev.dimension.flare.ui.presenter.gallery import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.paging.Pager -import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.PagingData import dev.dimension.flare.common.PagingState -import dev.dimension.flare.common.collectAsState -import dev.dimension.flare.common.onEmpty -import dev.dimension.flare.common.onError -import dev.dimension.flare.common.onSuccess -import dev.dimension.flare.common.toPagingState -import dev.dimension.flare.data.datasource.microblog.ActionMenu +import dev.dimension.flare.common.cachePagingState +import dev.dimension.flare.common.emptyFlow import dev.dimension.flare.data.datasource.microblog.datasource.GalleryDataSource import dev.dimension.flare.data.datasource.microblog.datasource.GalleryDetail -import dev.dimension.flare.data.datasource.microblog.datasource.GalleryOrientation -import dev.dimension.flare.data.datasource.microblog.datasource.PostDataSource import dev.dimension.flare.data.datasource.microblog.paging.toPagingSource import dev.dimension.flare.data.datasource.microblog.pagingConfig import dev.dimension.flare.data.repository.AccountRepository import dev.dimension.flare.data.repository.accountServiceFlow -import dev.dimension.flare.data.repository.accountServiceProvider import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiMedia import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.model.UiTimelineV2 -import dev.dimension.flare.ui.model.flatMap -import dev.dimension.flare.ui.model.map -import dev.dimension.flare.ui.model.postEventOrNull +import dev.dimension.flare.ui.model.flattenUiState import dev.dimension.flare.ui.model.toUi import dev.dimension.flare.ui.presenter.PresenterBase import dev.dimension.flare.ui.presenter.status.LogStatusHistoryPresenter -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.launch +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flatMapLatest import org.koin.core.component.KoinComponent import org.koin.core.component.inject +@OptIn(ExperimentalCoroutinesApi::class) public class GalleryDetailPresenter( private val accountType: AccountType, private val statusKey: MicroBlogKey, @@ -45,60 +37,65 @@ public class GalleryDetailPresenter( KoinComponent { private val accountRepository: AccountRepository by inject() + private val serviceFlow by lazy { + accountServiceFlow( + accountType = accountType, + repository = accountRepository, + ) + } + + private val detailCacheFlow by lazy { + serviceFlow.flatMapLatest { service -> + val galleryDataSource = + service as? GalleryDataSource + ?: error("Current service does not support gallery data source") + galleryDataSource.galleryDetail(statusKey).toUi() + } + } + + private val commentsFlow by lazy { + serviceFlow.flatMapLatest { service -> + runCatching { + val galleryDataSource = + service as? GalleryDataSource + ?: error("Current service does not support gallery data source") + Pager(config = pagingConfig) { + galleryDataSource.galleryComments(statusKey).toPagingSource() + }.flow + }.getOrElse { + PagingData.emptyFlow(isError = true) + } + } + } + + private val recommendationsFlow by lazy { + serviceFlow.flatMapLatest { service -> + runCatching { + val galleryDataSource = + service as? GalleryDataSource + ?: error("Current service does not support gallery data source") + Pager(config = pagingConfig) { + galleryDataSource.galleryRecommendations(statusKey).toPagingSource() + }.flow + }.getOrElse { + PagingData.emptyFlow(isError = true) + } + } + } + @Immutable public interface State { public val detail: UiState public val comments: PagingState public val recommendations: PagingState - - public fun performAction(action: ActionMenu.Item) - - public fun refresh() } @Composable override fun body(): State { val scope = rememberCoroutineScope() - val serviceState = accountServiceProvider(accountType = accountType, repository = accountRepository) - val postCache = - serviceState.map { service -> - val postDataSource = - service as? PostDataSource - ?: error("Current service does not support post data source") - remember(service, statusKey) { - postDataSource.postHandler.post(statusKey) - }.collectAsState() - } - val detail = - postCache.flatMap { cacheState -> - cacheState - .toUi() - .map { it.toGalleryDetail() } - } - val comments = - serviceState - .map { service -> - val galleryDataSource = - service as? GalleryDataSource - ?: error("Current service does not support gallery data source") - remember(service, statusKey) { - Pager(config = pagingConfig) { - galleryDataSource.galleryComments(statusKey).toPagingSource() - }.flow - }.collectAsLazyPagingItems() - }.toPagingState() - val recommendations = - serviceState - .map { service -> - val galleryDataSource = - service as? GalleryDataSource - ?: error("Current service does not support gallery data source") - remember(service, statusKey) { - Pager(config = pagingConfig) { - galleryDataSource.galleryRecommendations(statusKey).toPagingSource() - }.flow - }.collectAsLazyPagingItems() - }.toPagingState() + val detail by detailCacheFlow.flattenUiState() + val comments = commentsFlow.cachePagingState(scope) + val recommendations = recommendationsFlow.cachePagingState(scope) remember { LogStatusHistoryPresenter(accountType = accountType, statusKey = statusKey) }.body() @@ -106,56 +103,6 @@ public class GalleryDetailPresenter( override val detail: UiState = detail override val comments: PagingState = comments override val recommendations: PagingState = recommendations - - override fun performAction(action: ActionMenu.Item) { - val event = action.clickEvent.postEventOrNull() ?: return - scope.launch { - accountServiceFlow( - accountType = AccountType.Specific(event.accountKey), - repository = accountRepository, - ).firstOrNull()?.let { service -> - (service as? PostDataSource)?.postEventHandler?.handleEvent(event.postEvent) - } - } - } - - override fun refresh() { - if (postCache is UiState.Success) { - postCache.data.refresh() - } - scope.launch { - comments - .onSuccess { - refreshSuspend() - }.onEmpty { - refresh() - }.onError { - onRetry() - } - recommendations - .onSuccess { - refreshSuspend() - }.onEmpty { - refresh() - }.onError { - onRetry() - } - } - } } } } - -private fun UiTimelineV2.toGalleryDetail(): GalleryDetail { - val post = this as? UiTimelineV2.Post ?: error("Gallery detail should be a post") - val firstImage = post.images.filterIsInstance().firstOrNull() - return GalleryDetail( - post = post, - orientation = - if ((firstImage?.aspectRatio ?: 1f) >= 1f) { - GalleryOrientation.Horizontal - } else { - GalleryOrientation.Vertical - }, - ) -} diff --git a/social/pixiv/src/commonMain/kotlin/dev/dimension/flare/data/datasource/pixiv/PixivDataSource.kt b/social/pixiv/src/commonMain/kotlin/dev/dimension/flare/data/datasource/pixiv/PixivDataSource.kt index 050443873..9ac1f8ea1 100644 --- a/social/pixiv/src/commonMain/kotlin/dev/dimension/flare/data/datasource/pixiv/PixivDataSource.kt +++ b/social/pixiv/src/commonMain/kotlin/dev/dimension/flare/data/datasource/pixiv/PixivDataSource.kt @@ -1,11 +1,14 @@ package dev.dimension.flare.data.datasource.pixiv +import dev.dimension.flare.common.Cacheable +import dev.dimension.flare.data.datasource.microblog.ActionMenu import dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataSource 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.GalleryDataSource import dev.dimension.flare.data.datasource.microblog.datasource.GalleryDetail +import dev.dimension.flare.data.datasource.microblog.datasource.GalleryOrientation import dev.dimension.flare.data.datasource.microblog.datasource.PinnableTimelineTabDataSource import dev.dimension.flare.data.datasource.microblog.datasource.PinnableTimelineTabSection import dev.dimension.flare.data.datasource.microblog.datasource.PostDataSource @@ -31,14 +34,17 @@ import dev.dimension.flare.data.platform.PixivCredential import dev.dimension.flare.data.platform.PixivPlatformSpec import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.ClickEvent import dev.dimension.flare.ui.model.UiHashtag 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.UiStrings import dev.dimension.flare.ui.model.UiText import dev.dimension.flare.ui.model.UiTimelineV2 import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow internal class PixivDataSource( @@ -290,12 +296,52 @@ internal class PixivDataSource( statusKey = statusKey, ) - override fun galleryDetail(statusKey: MicroBlogKey): RemoteLoader = - PixivGalleryDetailLoader( - service = service, - accountKey = accountKey, - statusKey = statusKey, - ) + @Suppress("UNCHECKED_CAST") + override fun galleryDetail(statusKey: MicroBlogKey): Cacheable = + postHandler.post(statusKey).map { + val post = it as? UiTimelineV2.Post ?: error("Gallery detail should be a post") + val actionItems = post.actions.filterIsInstance() + val bookmarkAction = + actionItems.firstOrNull { action -> + val text = action.text as? ActionMenu.Item.Text.Localized + text?.type == ActionMenu.Item.Text.Localized.Type.Bookmark || + text?.type == ActionMenu.Item.Text.Localized.Type.Unbookmark + } + val bookmarkText = bookmarkAction?.text as? ActionMenu.Item.Text.Localized + val bookmarked = bookmarkText?.type == ActionMenu.Item.Text.Localized.Type.Unbookmark + val bookmarkCount = bookmarkAction?.count?.value ?: 0L + GalleryDetail( + orientation = GalleryOrientation.Vertical, + statusKey = post.statusKey, + accountType = post.accountType, + url = "https://www.pixiv.net/artworks/${post.statusKey.id}", + images = post.images.filterIsInstance().toImmutableList(), + title = post.contentWarning?.raw.orEmpty(), + author = post.user, + createdAt = post.createdAt, + content = post.content.takeUnless { content -> content.isEmpty }, + isBookmarked = bookmarked, + bookmarkAction = + ClickEvent.event( + accountKey = accountKey, + postEvent = + PostEvent.Pixiv.Bookmark( + postKey = post.statusKey, + bookmarked = bookmarked, + count = bookmarkCount, + accountKey = accountKey, + ), + ), + matrix = + actionItems + .mapNotNull { action -> + GalleryDetail.Matrix( + icon = action.icon ?: return@mapNotNull null, + count = action.count?.value ?: return@mapNotNull null, + ) + }.toImmutableList(), + ) + } as Cacheable override fun galleryComments(statusKey: MicroBlogKey): RemoteLoader = PixivGalleryCommentsLoader( diff --git a/social/pixiv/src/commonMain/kotlin/dev/dimension/flare/data/datasource/pixiv/PixivMapper.kt b/social/pixiv/src/commonMain/kotlin/dev/dimension/flare/data/datasource/pixiv/PixivMapper.kt index cc0ae7c6a..56fb6712e 100644 --- a/social/pixiv/src/commonMain/kotlin/dev/dimension/flare/data/datasource/pixiv/PixivMapper.kt +++ b/social/pixiv/src/commonMain/kotlin/dev/dimension/flare/data/datasource/pixiv/PixivMapper.kt @@ -2,8 +2,6 @@ package dev.dimension.flare.data.datasource.pixiv import dev.dimension.flare.data.datasource.microblog.ActionMenu import dev.dimension.flare.data.datasource.microblog.PostActionFamily -import dev.dimension.flare.data.datasource.microblog.datasource.GalleryDetail -import dev.dimension.flare.data.datasource.microblog.datasource.GalleryOrientation import dev.dimension.flare.data.network.pixiv.PIXIV_IMAGE_REFERER import dev.dimension.flare.data.network.pixiv.model.PixivComment import dev.dimension.flare.data.network.pixiv.model.PixivCommentStamp @@ -105,17 +103,6 @@ internal fun PixivIllust.toUiTimeline(accountKey: MicroBlogKey): UiTimelineV2.Po ) } -internal fun PixivIllust.toGalleryDetail(accountKey: MicroBlogKey): GalleryDetail = - GalleryDetail( - post = toUiTimeline(accountKey), - orientation = - if (width >= height) { - GalleryOrientation.Horizontal - } else { - GalleryOrientation.Vertical - }, - ) - internal fun PixivComment.toUiTimeline( accountKey: MicroBlogKey, illustKey: MicroBlogKey, diff --git a/social/pixiv/src/commonMain/kotlin/dev/dimension/flare/data/datasource/pixiv/PixivTimelineLoaders.kt b/social/pixiv/src/commonMain/kotlin/dev/dimension/flare/data/datasource/pixiv/PixivTimelineLoaders.kt index 6853cc93a..ba765f882 100644 --- a/social/pixiv/src/commonMain/kotlin/dev/dimension/flare/data/datasource/pixiv/PixivTimelineLoaders.kt +++ b/social/pixiv/src/commonMain/kotlin/dev/dimension/flare/data/datasource/pixiv/PixivTimelineLoaders.kt @@ -1,6 +1,5 @@ package dev.dimension.flare.data.datasource.pixiv -import dev.dimension.flare.data.datasource.microblog.datasource.GalleryDetail 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 @@ -157,34 +156,6 @@ internal class PixivStatusDetailLoader( } } -internal class PixivGalleryDetailLoader( - private val service: PixivService, - private val accountKey: MicroBlogKey, - private val statusKey: MicroBlogKey, -) : CacheableRemoteLoader { - override val pagingKey: String = "pixiv_gallery_detail_${statusKey}_$accountKey" - - override suspend fun load( - pageSize: Int, - request: PagingRequest, - ): PagingResult { - if (request != PagingRequest.Refresh) { - return PagingResult(endOfPaginationReached = true) - } - - val illustId = statusKey.id.toLongOrNull() ?: return PagingResult(endOfPaginationReached = true) - val response = - service.illustDetail( - illustId = illustId, - ) - - return PagingResult( - data = listOf(response.illust.toGalleryDetail(accountKey)), - endOfPaginationReached = true, - ) - } -} - internal class PixivGalleryCommentsLoader( private val service: PixivService, private val accountKey: MicroBlogKey, From 7a7ec4da8a94b166451dc4f3d94adaa3cf135cbf Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sun, 21 Jun 2026 03:11:50 +0900 Subject: [PATCH 7/9] add file download --- iosApp/flare/Common/MediaSaver.swift | 137 ++++++++++++++++++++- iosApp/flare/Localizable.xcstrings | 39 ++++++ iosApp/flare/UI/Screen/ArticleScreen.swift | 76 ++++++++++-- 3 files changed, 244 insertions(+), 8 deletions(-) diff --git a/iosApp/flare/Common/MediaSaver.swift b/iosApp/flare/Common/MediaSaver.swift index 763c60173..68eaeb25d 100644 --- a/iosApp/flare/Common/MediaSaver.swift +++ b/iosApp/flare/Common/MediaSaver.swift @@ -6,10 +6,11 @@ import UIKit import SwiftUI import Drops -class MediaSaver: NSObject { +class MediaSaver: NSObject, UIDocumentPickerDelegate { private override init() {} static let shared = MediaSaver() + private var pendingFileExportDirectories: [ObjectIdentifier: URL] = [:] func saveImage(url: String, customHeaders: [String: String]? = nil) { if let remoteUrl = URL(string: url) { @@ -33,6 +34,16 @@ class MediaSaver: NSObject { downloadRemoteVideoToPhotos(from: remoteUrl, customHeaders: customHeaders) } + func saveFile(url: String, fileName: String, customHeaders: [String: String]? = nil) { + guard let remoteUrl = URL(string: url) else { + showSaveResult(success: false, mediaType: .file) + return + } + + showDownloadStarted(mediaType: .file) + downloadRemoteFileForExport(from: remoteUrl, fileName: fileName, customHeaders: customHeaders) + } + private func saveRemoteOriginalDataToPhotos(from url: URL, customHeaders: [String: String]?) { KingfisherManager.shared.downloader.downloadImage(with: url, options: kingfisherOptions(customHeaders: customHeaders), progressBlock: nil) { result in switch result { @@ -79,6 +90,36 @@ class MediaSaver: NSObject { }.resume() } + private func downloadRemoteFileForExport(from url: URL, fileName: String, customHeaders: [String: String]?) { + var request = URLRequest(url: url) + customHeaders?.forEach { key, value in + request.setValue(value, forHTTPHeaderField: key) + } + + URLSession.shared.downloadTask(with: request) { temporaryURL, response, error in + guard error == nil, + let temporaryURL, + Self.isSuccessfulHTTPResponse(response) else { + self.showSaveResult(success: false, mediaType: .file) + return + } + + let exportDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("flare-file-\(UUID().uuidString)", isDirectory: true) + let exportURL = exportDirectory + .appendingPathComponent(Self.safeFileName(fileName, fallbackURL: url, response: response)) + + do { + try FileManager.default.createDirectory(at: exportDirectory, withIntermediateDirectories: true) + try FileManager.default.copyItem(at: temporaryURL, to: exportURL) + self.presentFileExportPicker(fileURL: exportURL, cleanupDirectory: exportDirectory) + } catch { + try? FileManager.default.removeItem(at: exportDirectory) + self.showSaveResult(success: false, mediaType: .file) + } + }.resume() + } + nonisolated private func saveOriginalDataToPhotos(_ data: Data) { PHPhotoLibrary.shared().performChanges { let request = PHAssetCreationRequest.forAsset() @@ -118,6 +159,30 @@ class MediaSaver: NSObject { } } + nonisolated private static func isSuccessfulHTTPResponse(_ response: URLResponse?) -> Bool { + guard let response = response as? HTTPURLResponse else { + return true + } + return (200..<300).contains(response.statusCode) + } + + nonisolated private static func safeFileName(_ fileName: String, fallbackURL: URL, response: URLResponse?) -> String { + let sourceName = fileName.trimmedNonEmpty + ?? response?.suggestedFilename?.trimmedNonEmpty + ?? fallbackURL.lastPathComponent.trimmedNonEmpty + ?? "file" + let safeName = sourceName.map { character -> Character in + if character == "/" || + character == "\\" || + character.unicodeScalars.contains(where: { $0.value < 32 || $0.value == 127 }) { + return "_" + } + return character + } + let value = String(safeName).trimmedNonEmpty + return value ?? "file" + } + private func kingfisherOptions(customHeaders: [String: String]?) -> KingfisherOptionsInfo { guard let customHeaders, !customHeaders.isEmpty else { return [] @@ -152,11 +217,67 @@ class MediaSaver: NSObject { ) } } + + nonisolated private func presentFileExportPicker(fileURL: URL, cleanupDirectory: URL) { + Task { @MainActor in + guard let presenter = Self.topViewController() else { + try? FileManager.default.removeItem(at: cleanupDirectory) + self.showSaveResult(success: false, mediaType: .file) + return + } + + let picker = UIDocumentPickerViewController(forExporting: [fileURL], asCopy: true) + picker.delegate = self + self.pendingFileExportDirectories[ObjectIdentifier(picker)] = cleanupDirectory + presenter.present(picker, animated: true) + } + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + cleanupFileExport(for: controller) + showSaveResult(success: true, mediaType: .file) + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + cleanupFileExport(for: controller) + } + + private func cleanupFileExport(for controller: UIDocumentPickerViewController) { + let identifier = ObjectIdentifier(controller) + guard let cleanupDirectory = pendingFileExportDirectories.removeValue(forKey: identifier) else { + return + } + try? FileManager.default.removeItem(at: cleanupDirectory) + } + + private static func topViewController() -> UIViewController? { + let rootViewController = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive }? + .windows + .first { $0.isKeyWindow }? + .rootViewController + return topViewController(from: rootViewController) + } + + private static func topViewController(from viewController: UIViewController?) -> UIViewController? { + if let navigationController = viewController as? UINavigationController { + return topViewController(from: navigationController.visibleViewController) + } + if let tabBarController = viewController as? UITabBarController { + return topViewController(from: tabBarController.selectedViewController) + } + if let presentedViewController = viewController?.presentedViewController { + return topViewController(from: presentedViewController) + } + return viewController + } } private enum MediaSaveType { case image case video + case file func title(success: Bool) -> String { switch self { @@ -164,6 +285,11 @@ private enum MediaSaveType { return .init(localized: success ? "notification_save_image_success" : "notification_save_image_error") case .video: return .init(localized: success ? "notification_save_video_success" : "notification_save_video_error") + case .file: + if success { + return String(localized: "notification_save_file_success", defaultValue: "Saved to Files") + } + return String(localized: "notification_save_file_error", defaultValue: "Failed to save file") } } @@ -173,10 +299,19 @@ private enum MediaSaveType { return .init(localized: "notification_download_started") case .video: return .init(localized: "notification_download_video_started") + case .file: + return String(localized: "notification_download_file_started", defaultValue: "Download started") } } } +private extension String { + nonisolated var trimmedNonEmpty: String? { + let value = trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} + private struct CachedVideoConfiguration: Decodable { let info: CachedVideoInfo? let fragments: [CachedVideoFragment] diff --git a/iosApp/flare/Localizable.xcstrings b/iosApp/flare/Localizable.xcstrings index d34d38e90..a4ad8c57c 100644 --- a/iosApp/flare/Localizable.xcstrings +++ b/iosApp/flare/Localizable.xcstrings @@ -106477,6 +106477,19 @@ } } }, + "notification_download_file_started" : { + "comment" : "Title of a notification that is displayed when a file download starts.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Download started" + } + } + } + }, "notification_download_started" : { "localizations" : { "en" : { @@ -106711,6 +106724,32 @@ } } }, + "notification_save_file_error" : { + "comment" : "Title of a notification when a file is not saved.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Failed to save file" + } + } + } + }, + "notification_save_file_success" : { + "comment" : "Title of a notification when a file is saved successfully.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Saved to Files" + } + } + } + }, "notification_save_image_error" : { "extractionState" : "stale", "localizations" : { diff --git a/iosApp/flare/UI/Screen/ArticleScreen.swift b/iosApp/flare/UI/Screen/ArticleScreen.swift index d6a980cb2..cc3b193b5 100644 --- a/iosApp/flare/UI/Screen/ArticleScreen.swift +++ b/iosApp/flare/UI/Screen/ArticleScreen.swift @@ -28,7 +28,8 @@ struct ArticleScreen: View { article: article, accountType: accountType, onNavigate: onNavigate, - onOpenURL: openArticleURL + onOpenURL: openArticleURL, + onDownloadFile: downloadArticleFile ) } errorContent: { error in ContentUnavailableView( @@ -79,6 +80,14 @@ struct ArticleScreen: View { guard let url = URL(string: value) else { return } openURL(url) } + + private func downloadArticleFile(_ block: UiArticleBlockFile) { + MediaSaver.shared.saveFile( + url: block.url, + fileName: block.downloadFileName, + customHeaders: block.customHeaders + ) + } } private struct ArticleContentView: View { @@ -86,6 +95,7 @@ private struct ArticleContentView: View { let accountType: AccountType let onNavigate: (Route) -> Void let onOpenURL: (String) -> Void + let onDownloadFile: (UiArticleBlockFile) -> Void private var blocks: [any UiArticleBlock] { Array(article.content.blocks) @@ -115,7 +125,8 @@ private struct ArticleContentView: View { ArticleBlockView( block: block, onOpenURL: onOpenURL, - onOpenMedia: openMedia + onOpenMedia: openMedia, + onDownloadFile: onDownloadFile ) } } @@ -277,6 +288,7 @@ private struct ArticleBlockView: View { let block: any UiArticleBlock let onOpenURL: (String) -> Void let onOpenMedia: (any UiMedia) -> Void + let onDownloadFile: (UiArticleBlockFile) -> Void var body: some View { switch onEnum(of: block) { @@ -294,7 +306,7 @@ private struct ArticleBlockView: View { onOpenMedia: onOpenMedia ) case .file(let file): - ArticleFileBlockView(block: file, onOpenURL: onOpenURL) + ArticleFileBlockView(block: file, onDownloadFile: onDownloadFile) case .embed(let embed): ArticleEmbedBlockView(block: embed, onOpenURL: onOpenURL) case .contentGate(let gate): @@ -370,16 +382,16 @@ private struct ArticleMediaFrame: View { private struct ArticleFileBlockView: View { let block: UiArticleBlockFile - let onOpenURL: (String) -> Void + let onDownloadFile: (UiArticleBlockFile) -> Void private var extensionName: String? { - let extensionName = URL(string: block.url)?.pathExtension - return extensionName?.isEmpty == false ? extensionName?.uppercased() : nil + let extensionName = block.extension?.trimmedNonEmpty ?? URL(string: block.url)?.pathExtension.trimmedNonEmpty + return extensionName?.uppercased() } var body: some View { Button { - onOpenURL(block.url) + onDownloadFile(block) } label: { HStack(spacing: 12) { Image(systemName: "doc.fill") @@ -562,6 +574,56 @@ private struct ArticleLoadingView: View { } } +private extension UiArticleBlockFile { + var downloadFileName: String { + let sourceName = name.trimmedNonEmpty ?? url.fileNameFromPath ?? "file" + let rawExtension = self.extension?.trimmedNonEmpty + let extensionName = rawExtension.map { String($0.drop(while: { $0 == "." })) }?.trimmedNonEmpty + let fileName = if let extensionName, !sourceName.hasFileExtension { + "\(sourceName).\(extensionName)" + } else { + sourceName + } + return fileName.safeDownloadFileName + } +} + +private extension String { + var trimmedNonEmpty: String? { + let value = trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + + var fileNameFromPath: String? { + let path = components(separatedBy: CharacterSet(charactersIn: "?#")).first ?? self + return path + .split { $0 == "/" || $0 == "\\" } + .last + .map(String.init)? + .trimmedNonEmpty + } + + var hasFileExtension: Bool { + let name = fileNameFromPath ?? self + guard let lastDotIndex = name.lastIndex(of: ".") else { + return false + } + return lastDotIndex > name.startIndex && name.index(after: lastDotIndex) < name.endIndex + } + + var safeDownloadFileName: String { + let safeName = trimmingCharacters(in: .whitespacesAndNewlines).map { character -> Character in + if character == "/" || + character == "\\" || + character.unicodeScalars.contains(where: { $0.value < 32 || $0.value == 127 }) { + return "_" + } + return character + } + return String(safeName).trimmedNonEmpty ?? "file" + } +} + private func articleAspectRatio(_ value: Float) -> CGFloat { let ratio = CGFloat(value) guard ratio.isFinite, ratio > 0 else { return 16 / 9 } From 85ab479ac717ab93fcb866dd48b1f7808d2d4c87 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sun, 21 Jun 2026 12:11:59 +0900 Subject: [PATCH 8/9] fix test --- .../flare/data/model/tab/TimelineSpecIds.kt | 13 +++++-------- .../data/model/tab/TabSettingsMigrationTest.kt | 2 -- .../flare/data/platform/FanboxPlatformSpec.kt | 5 +++-- .../flare/data/platform/PixivPlatformSpec.kt | 7 ++++--- 4 files changed, 12 insertions(+), 15 deletions(-) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/tab/TimelineSpecIds.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/tab/TimelineSpecIds.kt index 7bd0ab404..d3e9941ea 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/tab/TimelineSpecIds.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/tab/TimelineSpecIds.kt @@ -2,6 +2,9 @@ package dev.dimension.flare.data.model.tab import kotlin.native.HiddenFromObjC +// This file is limited to shared/common timeline specs and IDs emitted by the +// v1 TabSettings migration. Platform-only timeline IDs belong in their owning +// platform module, not here. @HiddenFromObjC public object TimelineSpecIds { public const val COMMON_HOME: String = "common.home" @@ -35,11 +38,8 @@ public object TimelineSpecIds { public const val VVO_FAVORITE: String = "vvo.favorite" public const val VVO_LIKED: String = "vvo.liked" - public const val PIXIV_FOLLOWING: String = "pixiv.following" - public const val PIXIV_BOOKMARK: String = "pixiv.bookmark" - - public const val FANBOX_SUPPORTED: String = "fanbox.supported" - + // Keep this set in sync with TabSettingsMigration.toTimelineSlotOrNull. + // It is not a registry for every platform timeline spec. public val legacyMigrationIds: Set = setOf( COMMON_HOME, @@ -65,8 +65,5 @@ public object TimelineSpecIds { XQT_DEVICE_FOLLOW, VVO_FAVORITE, VVO_LIKED, - PIXIV_FOLLOWING, - PIXIV_BOOKMARK, - FANBOX_SUPPORTED, ) } diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/model/tab/TabSettingsMigrationTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/model/tab/TabSettingsMigrationTest.kt index 195d2cee5..5c6758ecd 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/model/tab/TabSettingsMigrationTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/model/tab/TabSettingsMigrationTest.kt @@ -228,8 +228,6 @@ class TabSettingsMigrationTest { TimelineSpecIds.XQT_DEVICE_FOLLOW, TimelineSpecIds.VVO_FAVORITE, TimelineSpecIds.VVO_LIKED, - TimelineSpecIds.PIXIV_FOLLOWING, - TimelineSpecIds.PIXIV_BOOKMARK, ) assertEquals(emptySet(), TimelineSpecIds.legacyMigrationIds - runtimeIds) diff --git a/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/platform/FanboxPlatformSpec.kt b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/platform/FanboxPlatformSpec.kt index 03901a221..388912b58 100644 --- a/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/platform/FanboxPlatformSpec.kt +++ b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/data/platform/FanboxPlatformSpec.kt @@ -5,7 +5,6 @@ import dev.dimension.flare.data.datasource.fanbox.fanboxCreatorKey import dev.dimension.flare.data.datasource.fanbox.fanboxPostKey import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource import dev.dimension.flare.data.model.tab.TimelineSpec -import dev.dimension.flare.data.model.tab.TimelineSpecIds import dev.dimension.flare.data.model.tab.accountLoader import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey @@ -25,6 +24,8 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.serialization.Serializable import kotlin.native.HiddenFromObjC +private const val FANBOX_SUPPORTED_TIMELINE_SPEC_ID: String = "fanbox.supported" + @HiddenFromObjC public data object FanboxPlatformSpec : PlatformSpec, @@ -38,7 +39,7 @@ public data object FanboxPlatformSpec : internal val supportedTimelineSpec = TimelineSpec( - id = TimelineSpecIds.FANBOX_SUPPORTED, + id = FANBOX_SUPPORTED_TIMELINE_SPEC_ID, title = UiStrings.FanboxSupported, icon = UiIcon.Heart.asType(), serializer = TimelineSpec.AccountBasedData.serializer(), diff --git a/social/pixiv/src/commonMain/kotlin/dev/dimension/flare/data/platform/PixivPlatformSpec.kt b/social/pixiv/src/commonMain/kotlin/dev/dimension/flare/data/platform/PixivPlatformSpec.kt index 5d1a4631e..c148fe952 100644 --- a/social/pixiv/src/commonMain/kotlin/dev/dimension/flare/data/platform/PixivPlatformSpec.kt +++ b/social/pixiv/src/commonMain/kotlin/dev/dimension/flare/data/platform/PixivPlatformSpec.kt @@ -3,7 +3,6 @@ package dev.dimension.flare.data.platform import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource import dev.dimension.flare.data.datasource.pixiv.PixivDataSource import dev.dimension.flare.data.model.tab.TimelineSpec -import dev.dimension.flare.data.model.tab.TimelineSpecIds import dev.dimension.flare.data.model.tab.accountLoader import dev.dimension.flare.data.network.pixiv.PixivRankingMode import dev.dimension.flare.model.AccountType @@ -37,7 +36,7 @@ public data object PixivPlatformSpec : internal val bookmarkTimelineSpec = TimelineSpec( - id = TimelineSpecIds.PIXIV_BOOKMARK, + id = PIXIV_BOOKMARK, title = UiStrings.Bookmark, icon = UiIcon.Bookmark.asType(), serializer = TimelineSpec.AccountBasedData.serializer(), @@ -50,7 +49,7 @@ public data object PixivPlatformSpec : internal val followingTimelineSpec = TimelineSpec( - id = TimelineSpecIds.PIXIV_FOLLOWING, + id = PIXIV_FOLLOWING, title = UiStrings.Following, icon = UiIcon.Follow.asType(), serializer = TimelineSpec.AccountBasedData.serializer(), @@ -179,6 +178,8 @@ private data class PixivUserDeepLink( public const val PIXIV_HOST: String = "pixiv.net" +private const val PIXIV_FOLLOWING: String = "pixiv.following" +private const val PIXIV_BOOKMARK: String = "pixiv.bookmark" private const val PIXIV_RANKING_WEEK: String = "pixiv.ranking.week" private const val PIXIV_RANKING_MONTH: String = "pixiv.ranking.month" private const val PIXIV_RANKING_DAY_MALE: String = "pixiv.ranking.day_male" From 46036e37d4e4ebcbba4fe9db4c89587203491651 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sun, 21 Jun 2026 12:44:51 +0900 Subject: [PATCH 9/9] fix gallery detail --- .../ui/screen/gallery/GalleryDetailScreen.kt | 103 ++++++------ app/src/main/res/values-ar-rSA/strings.xml | 4 +- app/src/main/res/values-el-rGR/strings.xml | 2 +- app/src/main/res/values-ja-rJP/strings.xml | 4 +- app/src/main/res/values-ru-rRU/strings.xml | 2 +- app/src/main/res/values-uk-rUA/strings.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 4 +- app/src/main/res/values/strings.xml | 8 + .../values-ar-rSA/strings.xml | 4 +- .../values-el-rGR/strings.xml | 2 +- .../values-ja-rJP/strings.xml | 4 +- .../values-ru-rRU/strings.xml | 2 +- .../values-uk-rUA/strings.xml | 2 +- .../values-zh-rCN/strings.xml | 4 +- .../flare/ui/common/PagingStateExt.kt | 3 +- .../values-ar-rSA/strings.xml | 4 +- .../values-el-rGR/strings.xml | 2 +- .../values-ja-rJP/strings.xml | 4 +- .../values-ru-rRU/strings.xml | 2 +- .../values-uk-rUA/strings.xml | 2 +- .../values-zh-rCN/strings.xml | 4 +- .../ui/screen/gallery/GalleryDetailScreen.kt | 146 +++++++++++------- iosApp/flare/Localizable.xcstrings | 14 +- web/messages/af-ZA.json | 2 + web/messages/ar-SA.json | 2 + web/messages/bg-BG.json | 2 + web/messages/ca-ES.json | 2 + web/messages/cs-CZ.json | 2 + web/messages/da-DK.json | 2 + web/messages/de-DE.json | 2 + web/messages/el-GR.json | 2 + web/messages/en-US.json | 2 + web/messages/en.json | 2 + web/messages/es-ES.json | 2 + web/messages/fi-FI.json | 2 + web/messages/fr-FR.json | 2 + web/messages/he-IL.json | 2 + web/messages/hu-HU.json | 2 + web/messages/it-IT.json | 2 + web/messages/ja-JP.json | 2 + web/messages/ko-KR.json | 2 + web/messages/nl-NL.json | 2 + web/messages/no-NO.json | 2 + web/messages/pl-PL.json | 2 + web/messages/pt-BR.json | 2 + web/messages/pt-PT.json | 2 + web/messages/ro-RO.json | 2 + web/messages/ru-RU.json | 2 + web/messages/sr-SP.json | 2 + web/messages/sv-SE.json | 2 + web/messages/tr-TR.json | 2 + web/messages/uk-UA.json | 2 + web/messages/vi-VN.json | 2 + web/messages/zh-CN.json | 2 + web/messages/zh-TW.json | 2 + web/src/lib/i18n/uiStrings.ts | 4 +- 56 files changed, 253 insertions(+), 143 deletions(-) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/gallery/GalleryDetailScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/gallery/GalleryDetailScreen.kt index fe17cb3ef..01da259fa 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/gallery/GalleryDetailScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/gallery/GalleryDetailScreen.kt @@ -48,8 +48,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -59,6 +61,7 @@ import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.CaretUp import compose.icons.fontawesomeicons.solid.EllipsisVertical import compose.icons.fontawesomeicons.solid.ShareNodes +import dev.dimension.flare.R import dev.dimension.flare.common.PagingState import dev.dimension.flare.common.onEmpty import dev.dimension.flare.common.onError @@ -69,6 +72,7 @@ import dev.dimension.flare.data.datasource.microblog.datasource.GalleryOrientati import dev.dimension.flare.data.model.TimelineDisplayMode import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.common.items import dev.dimension.flare.ui.component.AvatarComponent import dev.dimension.flare.ui.component.BackButton import dev.dimension.flare.ui.component.DateTimeText @@ -325,7 +329,8 @@ private fun CompactGalleryContent( item( span = StaggeredGridItemSpan.FullLine, ) { - val configuration = LocalConfiguration.current + val containerSize = LocalWindowInfo.current.containerSize + val screenHeightDp = with(LocalDensity.current) { containerSize.height.toDp() } val pagerState = rememberPagerState( pageCount = { images.size }, @@ -336,7 +341,7 @@ private fun CompactGalleryContent( Modifier .fillMaxWidth() .ignoreHorizontalParentPadding(screenHorizontalPadding) - .height(configuration.screenHeightDp.dp), + .height(screenHeightDp), ) { index -> val image = images[index] Box(Modifier.fillMaxSize()) { @@ -500,17 +505,17 @@ private fun GallerySideBar( Tab( selected = pagerState.currentPage == 0, onClick = { scope.launch { pagerState.animateScrollToPage(0) } }, - text = { Text("Info") }, + text = { Text(stringResource(R.string.gallery_detail_tab_info)) }, ) Tab( selected = pagerState.currentPage == 1, onClick = { scope.launch { pagerState.animateScrollToPage(1) } }, - text = { Text("Comments") }, + text = { Text(stringResource(R.string.gallery_detail_tab_comments)) }, ) Tab( selected = pagerState.currentPage == 2, onClick = { scope.launch { pagerState.animateScrollToPage(2) } }, - text = { Text("Recommend") }, + text = { Text(stringResource(R.string.gallery_detail_tab_recommend)) }, ) } HorizontalPager( @@ -786,7 +791,7 @@ private fun LazyStaggeredGridScope.galleryAfterImagesItems( navigate = navigate, ) item(span = StaggeredGridItemSpan.FullLine) { - SectionTitle("Recommendations") + SectionTitle(stringResource(R.string.gallery_detail_recommendations_title)) } recommendationItems( recommendations = recommendations, @@ -805,7 +810,7 @@ private fun LazyStaggeredGridScope.compactCommentsPreviewItems( val visibleCount = minOf(itemCount, 3) if (visibleCount > 0) { item(span = StaggeredGridItemSpan.FullLine) { - SectionTitle("Comments") + SectionTitle(stringResource(R.string.gallery_detail_comments_title)) } repeat(visibleCount) { index -> item(span = StaggeredGridItemSpan.FullLine) { @@ -822,30 +827,35 @@ private fun LazyStaggeredGridScope.compactCommentsPreviewItems( if ( visibleCount > 0 && ( - itemCount > 3 || - ( - appendState is LoadState.NotLoading && - !(appendState as LoadState.NotLoading).endOfPaginationReached + itemCount > 3 || + ( + appendState is LoadState.NotLoading && + !(appendState as LoadState.NotLoading).endOfPaginationReached + ) ) - ) ) { item(span = StaggeredGridItemSpan.FullLine) { Button( onClick = { - navigate(Route.Gallery.Comments(statusKey = statusKey, accountType = accountType)) + navigate( + Route.Gallery.Comments( + statusKey = statusKey, + accountType = accountType + ) + ) }, modifier = Modifier .fillMaxWidth(), ) { - Text("View more") + Text(stringResource(R.string.gallery_detail_view_more)) } } } } onLoading { item(span = StaggeredGridItemSpan.FullLine) { - SectionTitle("Comments") + SectionTitle(stringResource(R.string.gallery_detail_comments_title)) } repeat(3) { index -> item(span = StaggeredGridItemSpan.FullLine) { @@ -860,11 +870,10 @@ private fun LazyStaggeredGridScope.compactCommentsPreviewItems( } } onEmpty { - Unit } onError { error -> item(span = StaggeredGridItemSpan.FullLine) { - SectionTitle("Comments") + SectionTitle(stringResource(R.string.gallery_detail_comments_title)) } item(span = StaggeredGridItemSpan.FullLine) { ErrorContent(error = error, onRetry = onRetry) @@ -873,35 +882,33 @@ private fun LazyStaggeredGridScope.compactCommentsPreviewItems( } } -private fun androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope.recommendationItems( +private fun LazyStaggeredGridScope.recommendationItems( recommendations: PagingState, itemModifier: Modifier = Modifier, ) { - with(recommendations) { - onSuccess { - items( - count = itemCount, - key = itemKey { it.itemKey ?: it.hashCode() }, - ) { index -> - GalleryTimelineItem( - item = peek(index), - modifier = itemModifier, - ) - } - } - onLoading { - items(8) { - GalleryTimelineItem( - item = null, - modifier = itemModifier, - ) - } - } - onError { - item(span = StaggeredGridItemSpan.FullLine) { - ErrorContent(error = it, onRetry = onRetry) - } + items( + recommendations, + loadingContent = { + GalleryTimelineItem( + item = null, + modifier = itemModifier, + ) + }, + errorContent = { + ErrorContent( + error = it, + onRetry = { + recommendations.onError { + onRetry.invoke() + } + } + ) } + ) { + GalleryTimelineItem( + item = it, + modifier = itemModifier, + ) } } @@ -949,7 +956,7 @@ private fun GalleryTopAppBar( is UiState.Loading, is UiState.Error, - -> false + -> false } IconButton( enabled = shareEnabled, @@ -957,13 +964,13 @@ private fun GalleryTopAppBar( ) { FAIcon( imageVector = FontAwesomeIcons.Solid.ShareNodes, - contentDescription = "Share", + contentDescription = stringResource(R.string.gallery_detail_share_content_description), ) } IconButton(onClick = {}) { FAIcon( imageVector = FontAwesomeIcons.Solid.EllipsisVertical, - contentDescription = "More", + contentDescription = stringResource(R.string.more), ) } } else { @@ -973,7 +980,7 @@ private fun GalleryTopAppBar( is UiState.Loading, is UiState.Error, - -> false + -> false } IconButton( enabled = expandEnabled, @@ -981,7 +988,7 @@ private fun GalleryTopAppBar( ) { FAIcon( imageVector = FontAwesomeIcons.Solid.CaretUp, - contentDescription = "Expand", + contentDescription = stringResource(R.string.gallery_detail_expand_content_description), ) } } diff --git a/app/src/main/res/values-ar-rSA/strings.xml b/app/src/main/res/values-ar-rSA/strings.xml index 22b226698..25c6361cb 100644 --- a/app/src/main/res/values-ar-rSA/strings.xml +++ b/app/src/main/res/values-ar-rSA/strings.xml @@ -448,8 +448,8 @@ المنشورات والردود الإعجابات الوسائط - التوضيحات - Manga + الرسوم التوضيحية + مانغا الترتيب الأسبوعي الترتيب الشهري ترتيب الذكور diff --git a/app/src/main/res/values-el-rGR/strings.xml b/app/src/main/res/values-el-rGR/strings.xml index dccd44c2c..99a8b3f16 100644 --- a/app/src/main/res/values-el-rGR/strings.xml +++ b/app/src/main/res/values-el-rGR/strings.xml @@ -429,7 +429,7 @@ Μου αρέσει Μέσα Εικονογραφήσεις - Manga + Μάνγκα Εβδομαδιαία Κατάταξη Μηνιαία Κατάταξη Αρσενική Κατάταξη diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 7f2a79da3..56315a5cc 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -424,8 +424,8 @@ 投稿と返信 いいね メディア - Illustrations - Manga + イラスト + マンガ 今週のランキング 毎月のランキング 男性ランキング diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index bf466a345..1dc584558 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -443,7 +443,7 @@ Лайки Медиа Иллюстрации - Manga + Манга Рейтинг за неделю Рейтинг за месяц Рейтинг мужчин diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 9d0896c27..8344b0345 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -437,7 +437,7 @@ Вподобайки Медіа Ілюстрації - Manga + Манґа Щотижневий рейтинг Щомісячний рейтинг Чоловічий рейтинг diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 132ed1ccc..68693f15f 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -439,8 +439,8 @@ 动态与回复 喜欢 媒体 - 说明 - Manga + 插画 + 漫画 每周排名 每月排名 男性排名 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 76a5d76c2..c81246c49 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -331,6 +331,14 @@ Always reveal post content hidden by content warnings Preserve media aspect ratio Show media at its original aspect ratio in timelines + Info + Comments + Recommend + Comments + Recommendations + View more + Share + Expand Video autoplay Automatically play videos in posts Wi-Fi only diff --git a/compose-ui/src/commonMain/composeResources/values-ar-rSA/strings.xml b/compose-ui/src/commonMain/composeResources/values-ar-rSA/strings.xml index 172bcd103..29c566dac 100644 --- a/compose-ui/src/commonMain/composeResources/values-ar-rSA/strings.xml +++ b/compose-ui/src/commonMain/composeResources/values-ar-rSA/strings.xml @@ -416,7 +416,7 @@ الترتيب الأصلي تصنيف الروكيه Manga Ranking - التوضيحات - Manga + الرسوم التوضيحية + مانغا Flare diff --git a/compose-ui/src/commonMain/composeResources/values-el-rGR/strings.xml b/compose-ui/src/commonMain/composeResources/values-el-rGR/strings.xml index 8fddeb2ca..b70d61a75 100644 --- a/compose-ui/src/commonMain/composeResources/values-el-rGR/strings.xml +++ b/compose-ui/src/commonMain/composeResources/values-el-rGR/strings.xml @@ -417,6 +417,6 @@ Κατάταξη Rookie Manga Ranking Εικονογραφήσεις - Manga + Μάνγκα Flare diff --git a/compose-ui/src/commonMain/composeResources/values-ja-rJP/strings.xml b/compose-ui/src/commonMain/composeResources/values-ja-rJP/strings.xml index 9b08bf3e1..9654df2bb 100644 --- a/compose-ui/src/commonMain/composeResources/values-ja-rJP/strings.xml +++ b/compose-ui/src/commonMain/composeResources/values-ja-rJP/strings.xml @@ -415,8 +415,8 @@ 元のランキング ルーキーランキング Manga Ranking - Illustrations - Manga + イラスト + マンガ Flare 投稿と返信 diff --git a/compose-ui/src/commonMain/composeResources/values-ru-rRU/strings.xml b/compose-ui/src/commonMain/composeResources/values-ru-rRU/strings.xml index a682680d4..56e7e3bc1 100644 --- a/compose-ui/src/commonMain/composeResources/values-ru-rRU/strings.xml +++ b/compose-ui/src/commonMain/composeResources/values-ru-rRU/strings.xml @@ -417,6 +417,6 @@ Рейтинг Новобранцев Manga Ranking Иллюстрации - Manga + Манга Flare diff --git a/compose-ui/src/commonMain/composeResources/values-uk-rUA/strings.xml b/compose-ui/src/commonMain/composeResources/values-uk-rUA/strings.xml index f7092328a..dcbefab29 100644 --- a/compose-ui/src/commonMain/composeResources/values-uk-rUA/strings.xml +++ b/compose-ui/src/commonMain/composeResources/values-uk-rUA/strings.xml @@ -417,6 +417,6 @@ Рейтинг Нових Очків Manga Ranking Ілюстрації - Manga + Манґа Flare diff --git a/compose-ui/src/commonMain/composeResources/values-zh-rCN/strings.xml b/compose-ui/src/commonMain/composeResources/values-zh-rCN/strings.xml index 7b389bce0..e7744d1fa 100644 --- a/compose-ui/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/compose-ui/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -416,7 +416,7 @@ 原始排名 Rookie 排名 Manga Ranking - 说明 - Manga + 插画 + 漫画 Flare diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/common/PagingStateExt.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/common/PagingStateExt.kt index 8034354ff..0b9feb83a 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/common/PagingStateExt.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/common/PagingStateExt.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemScope import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.runtime.Composable import dev.dimension.flare.common.PagingState import dev.dimension.flare.common.onEmpty @@ -149,7 +150,7 @@ public fun LazyStaggeredGridScope.items( loadingContent() } }.onError { - item { + item(span = StaggeredGridItemSpan.FullLine) { errorContent(it) } }.onEmpty { diff --git a/desktopApp/src/main/composeResources/values-ar-rSA/strings.xml b/desktopApp/src/main/composeResources/values-ar-rSA/strings.xml index 2b6b42eab..76a2b188c 100644 --- a/desktopApp/src/main/composeResources/values-ar-rSA/strings.xml +++ b/desktopApp/src/main/composeResources/values-ar-rSA/strings.xml @@ -39,8 +39,8 @@ المنشورات والردود الإعجابات الوسائط - التوضيحات - Manga + الرسوم التوضيحية + مانغا الترتيب الأسبوعي الترتيب الشهري ترتيب الذكور diff --git a/desktopApp/src/main/composeResources/values-el-rGR/strings.xml b/desktopApp/src/main/composeResources/values-el-rGR/strings.xml index 0e66c64dc..9bded4102 100644 --- a/desktopApp/src/main/composeResources/values-el-rGR/strings.xml +++ b/desktopApp/src/main/composeResources/values-el-rGR/strings.xml @@ -36,7 +36,7 @@ Μου αρέσει Πολυμέσα Εικονογραφήσεις - Manga + Μάνγκα Εβδομαδιαία Κατάταξη Μηνιαία Κατάταξη Αρσενική Κατάταξη diff --git a/desktopApp/src/main/composeResources/values-ja-rJP/strings.xml b/desktopApp/src/main/composeResources/values-ja-rJP/strings.xml index c24da825f..cb0d52afa 100644 --- a/desktopApp/src/main/composeResources/values-ja-rJP/strings.xml +++ b/desktopApp/src/main/composeResources/values-ja-rJP/strings.xml @@ -34,8 +34,8 @@ 投稿と返信 いいね メディア - Illustrations - Manga + イラスト + マンガ 今週のランキング 毎月のランキング 男性ランキング diff --git a/desktopApp/src/main/composeResources/values-ru-rRU/strings.xml b/desktopApp/src/main/composeResources/values-ru-rRU/strings.xml index 9efdbf80f..452f3c18c 100644 --- a/desktopApp/src/main/composeResources/values-ru-rRU/strings.xml +++ b/desktopApp/src/main/composeResources/values-ru-rRU/strings.xml @@ -38,7 +38,7 @@ Лайки Медиа Иллюстрации - Manga + Манга Рейтинг за неделю Рейтинг за месяц Рейтинг мужчин diff --git a/desktopApp/src/main/composeResources/values-uk-rUA/strings.xml b/desktopApp/src/main/composeResources/values-uk-rUA/strings.xml index 516a8617c..a0f1e0db3 100644 --- a/desktopApp/src/main/composeResources/values-uk-rUA/strings.xml +++ b/desktopApp/src/main/composeResources/values-uk-rUA/strings.xml @@ -38,7 +38,7 @@ Вподобайки Медіа Ілюстрації - Manga + Манґа Щотижневий рейтинг Щомісячний рейтинг Чоловічий рейтинг diff --git a/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml b/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml index 4613a8fb3..e56a05c5a 100644 --- a/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml +++ b/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml @@ -37,8 +37,8 @@ 已拉黑的用户 你已拉黑此用户。其标签页和时间线会被隐藏,直到你手动选择显示。 显示 - 说明 - Manga + 插画 + 漫画 每周排名 每月排名 男性排名 diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/gallery/GalleryDetailScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/gallery/GalleryDetailScreen.kt index d7a01abe4..d8301af74 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/gallery/GalleryDetailScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/gallery/GalleryDetailScreen.kt @@ -61,12 +61,14 @@ import dev.dimension.flare.ui.component.FlareScrollBar import dev.dimension.flare.ui.component.LocalTimelineAppearance import dev.dimension.flare.ui.component.NetworkImage import dev.dimension.flare.ui.component.RichText +import dev.dimension.flare.ui.component.ignoreHorizontalParentPadding import dev.dimension.flare.ui.component.placeholder import dev.dimension.flare.ui.component.status.AdaptiveCard import dev.dimension.flare.ui.component.status.GalleryTimelineItem import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid import dev.dimension.flare.ui.component.status.StatusActionButton import dev.dimension.flare.ui.component.status.StatusItem +import dev.dimension.flare.ui.component.status.appendStateUI import dev.dimension.flare.ui.component.status.status import dev.dimension.flare.ui.component.toImageVector import dev.dimension.flare.ui.model.ClickContext @@ -91,6 +93,7 @@ import moe.tlaster.precompose.molecule.producePresenter private val GalleryGridSpacing = 8.dp private val CompactTimelineSpacing = 2.dp +private val CompactRecommendationBottomSpacing = GalleryGridSpacing - CompactTimelineSpacing private val SideBarWidth = 380.dp @Composable @@ -205,24 +208,12 @@ private fun CompactGalleryContent( verticalItemSpacing = CompactTimelineSpacing, modifier = Modifier.fillMaxSize(), ) { - item(span = StaggeredGridItemSpan.FullLine) { - GalleryImages( - detail = detail, - onMediaClick = { media -> - navigate(detail.statusMediaRoute(media)) - }, - modifier = - Modifier - .fillMaxWidth() - .let { - if (detail.orientation == GalleryOrientation.Horizontal) { - it.height(520.dp) - } else { - it - } - }, - ) - } + galleryImageItems( + detail = detail, + onMediaClick = { media -> + navigate(detail.statusMediaRoute(media)) + }, + ) item(span = StaggeredGridItemSpan.FullLine) { Spacer(Modifier.height(12.dp)) } @@ -302,7 +293,10 @@ private fun GalleryImagePane( Arrangement.Top }, ) { - items(images) { image -> + items( + items = images, + key = { it.url }, + ) { image -> GalleryImage( image = image, onClick = { onMediaClick(image) }, @@ -337,63 +331,87 @@ private fun GalleryImagePane( } } -@Composable -private fun GalleryImages( +private fun LazyStaggeredGridScope.galleryImageItems( detail: GalleryDetail, onMediaClick: (UiMedia.Image) -> Unit, - modifier: Modifier = Modifier, ) { val images = detail.images if (images.isEmpty()) { - Box( - modifier = - modifier - .height(320.dp) - .placeholder(true), - ) + item(span = StaggeredGridItemSpan.FullLine) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(320.dp) + .placeholder(true), + ) + } return } when (detail.orientation) { GalleryOrientation.Vertical -> { - Column(modifier = modifier) { - images.forEach { image -> - GalleryImage( - image = image, - onClick = { onMediaClick(image) }, - ) - } + items( + items = images, + key = { it.url }, + span = { StaggeredGridItemSpan.FullLine }, + ) { image -> + GalleryImage( + image = image, + onClick = { onMediaClick(image) }, + modifier = Modifier.ignoreHorizontalParentPadding(screenHorizontalPadding), + ) } } GalleryOrientation.Horizontal -> { - val pagerState = - rememberPagerState( - pageCount = { images.size }, - ) - HorizontalFlipView( - state = pagerState, - modifier = modifier, - ) { index -> - val image = images[index] - NetworkImage( - model = image.url, - contentDescription = image.description, - customHeaders = image.customHeaders, - contentScale = ContentScale.Fit, + item(span = StaggeredGridItemSpan.FullLine) { + GalleryHorizontalImages( + images = images, + onMediaClick = onMediaClick, modifier = Modifier - .fillMaxSize() - .clickable { onMediaClick(image) }, + .fillMaxWidth() + .height(520.dp) + .ignoreHorizontalParentPadding(screenHorizontalPadding), ) } } } } +@Composable +private fun GalleryHorizontalImages( + images: List, + onMediaClick: (UiMedia.Image) -> Unit, + modifier: Modifier = Modifier, +) { + val pagerState = + rememberPagerState( + pageCount = { images.size }, + ) + HorizontalFlipView( + state = pagerState, + modifier = modifier, + ) { index -> + val image = images[index] + NetworkImage( + model = image.url, + contentDescription = image.description, + customHeaders = image.customHeaders, + contentScale = ContentScale.Fit, + modifier = + Modifier + .fillMaxSize() + .clickable { onMediaClick(image) }, + ) + } +} + @Composable private fun GalleryImage( image: UiMedia.Image, onClick: () -> Unit, + modifier: Modifier = Modifier, ) { NetworkImage( model = image.url, @@ -401,9 +419,9 @@ private fun GalleryImage( customHeaders = image.customHeaders, contentScale = ContentScale.FillWidth, modifier = - Modifier - .aspectRatio(image.aspectRatio) + modifier .fillMaxWidth() + .aspectRatio(image.aspectRatio) .clickable(onClick = onClick), ) } @@ -742,7 +760,10 @@ private fun LazyStaggeredGridScope.galleryAfterImagesItems( item(span = StaggeredGridItemSpan.FullLine) { SectionTitle("Recommendations") } - recommendationItems(recommendations) + recommendationItems( + recommendations = recommendations, + itemModifier = Modifier.padding(bottom = CompactRecommendationBottomSpacing), + ) } private fun LazyStaggeredGridScope.compactCommentsPreviewItems( @@ -765,7 +786,7 @@ private fun LazyStaggeredGridScope.compactCommentsPreviewItems( totalCount = visibleCount, respectTimelineMode = true, ) { - StatusItem(peek(index)) + StatusItem(get(index)) } } } @@ -821,21 +842,30 @@ private fun LazyStaggeredGridScope.compactCommentsPreviewItems( } } -private fun LazyStaggeredGridScope.recommendationItems(recommendations: PagingState) { +private fun LazyStaggeredGridScope.recommendationItems( + recommendations: PagingState, + itemModifier: Modifier = Modifier, +) { with(recommendations) { onSuccess { items( count = itemCount, key = itemKey { it.itemKey ?: it.hashCode() }, + contentType = itemContentType { it.itemType }, ) { index -> GalleryTimelineItem( - item = peek(index), + item = get(index), + modifier = itemModifier, ) } + appendStateUI(this) } onLoading { items(8) { - GalleryTimelineItem(item = null) + GalleryTimelineItem( + item = null, + modifier = itemModifier, + ) } } onError { diff --git a/iosApp/flare/Localizable.xcstrings b/iosApp/flare/Localizable.xcstrings index a4ad8c57c..fab4eab86 100644 --- a/iosApp/flare/Localizable.xcstrings +++ b/iosApp/flare/Localizable.xcstrings @@ -47012,7 +47012,7 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "التوضيحات" + "value" : "الرسوم التوضيحية" } }, "bg" : { @@ -47096,7 +47096,7 @@ "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Illustrations" + "value" : "イラスト" } }, "ko" : { @@ -57089,7 +57089,7 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "Manga" + "value" : "مانغا" } }, "bg" : { @@ -57125,7 +57125,7 @@ "el" : { "stringUnit" : { "state" : "translated", - "value" : "Manga" + "value" : "Μάνγκα" } }, "en" : { @@ -57173,7 +57173,7 @@ "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Manga" + "value" : "マンガ" } }, "ko" : { @@ -57221,7 +57221,7 @@ "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Manga" + "value" : "Манга" } }, "sr" : { @@ -57245,7 +57245,7 @@ "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Manga" + "value" : "Манґа" } }, "vi" : { diff --git a/web/messages/af-ZA.json b/web/messages/af-ZA.json index a78e5a7fa..e6d68795a 100644 --- a/web/messages/af-ZA.json +++ b/web/messages/af-ZA.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Kanale", "homeTabMixedTimelineTitle": "Gemeng", "homeTabSocialTimelineTitle": "Sosiaal", + "illustrationsTitle": "Illustrasies", + "mangaTitle": "Manga", "mastodonTabLocalTitle": "Plaaslik", "mastodonTabPublicTitle": "Publiek", "dmListTitle": "Direkte Boodskappe", diff --git a/web/messages/ar-SA.json b/web/messages/ar-SA.json index 3d3ba72dc..c07ac4fa1 100644 --- a/web/messages/ar-SA.json +++ b/web/messages/ar-SA.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "القنوات", "homeTabMixedTimelineTitle": "مختلط", "homeTabSocialTimelineTitle": "اجتماعي", + "illustrationsTitle": "الرسوم التوضيحية", + "mangaTitle": "مانغا", "mastodonTabLocalTitle": "محلي", "mastodonTabPublicTitle": "عام", "dmListTitle": "الرسائل المباشرة", diff --git a/web/messages/bg-BG.json b/web/messages/bg-BG.json index fe901f661..9747eddd0 100644 --- a/web/messages/bg-BG.json +++ b/web/messages/bg-BG.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Канали", "homeTabMixedTimelineTitle": "Смесена", "homeTabSocialTimelineTitle": "Социална", + "illustrationsTitle": "Илюстрации", + "mangaTitle": "Манга", "mastodonTabLocalTitle": "Локално", "mastodonTabPublicTitle": "Публично", "dmListTitle": "Директни съобщения", diff --git a/web/messages/ca-ES.json b/web/messages/ca-ES.json index 3f4fdc2cb..9bbeaf6c9 100644 --- a/web/messages/ca-ES.json +++ b/web/messages/ca-ES.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Canals", "homeTabMixedTimelineTitle": "Mixt", "homeTabSocialTimelineTitle": "Social", + "illustrationsTitle": "Il·lustracions", + "mangaTitle": "Manga", "mastodonTabLocalTitle": "Local", "mastodonTabPublicTitle": "Públic", "dmListTitle": "Missatges directes", diff --git a/web/messages/cs-CZ.json b/web/messages/cs-CZ.json index 33e1d01e9..3e3d3f14e 100644 --- a/web/messages/cs-CZ.json +++ b/web/messages/cs-CZ.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Kanály", "homeTabMixedTimelineTitle": "Smíšené", "homeTabSocialTimelineTitle": "Sociální", + "illustrationsTitle": "Ilustrace", + "mangaTitle": "Manga", "mastodonTabLocalTitle": "Místní", "mastodonTabPublicTitle": "Veřejné", "dmListTitle": "Přímé zprávy", diff --git a/web/messages/da-DK.json b/web/messages/da-DK.json index 11d6c2b85..9406df899 100644 --- a/web/messages/da-DK.json +++ b/web/messages/da-DK.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Kanaler", "homeTabMixedTimelineTitle": "Blandet", "homeTabSocialTimelineTitle": "Social", + "illustrationsTitle": "Illustrationer", + "mangaTitle": "Manga", "mastodonTabLocalTitle": "Lokal", "mastodonTabPublicTitle": "Offentlig", "dmListTitle": "Direkte beskeder", diff --git a/web/messages/de-DE.json b/web/messages/de-DE.json index 3120fc45a..21c27d47d 100644 --- a/web/messages/de-DE.json +++ b/web/messages/de-DE.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Kanäle", "homeTabMixedTimelineTitle": "Gemischt", "homeTabSocialTimelineTitle": "Sozial", + "illustrationsTitle": "Illustrationen", + "mangaTitle": "Manga", "mastodonTabLocalTitle": "Lokal", "mastodonTabPublicTitle": "Öffentlich", "dmListTitle": "Direktnachrichten", diff --git a/web/messages/el-GR.json b/web/messages/el-GR.json index a21d5d812..473b5d4de 100644 --- a/web/messages/el-GR.json +++ b/web/messages/el-GR.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Κανάλια", "homeTabMixedTimelineTitle": "Μεικτό", "homeTabSocialTimelineTitle": "Κοινωνικό", + "illustrationsTitle": "Εικονογραφήσεις", + "mangaTitle": "Μάνγκα", "mastodonTabLocalTitle": "Τοπικά", "mastodonTabPublicTitle": "Δημόσια", "dmListTitle": "Άμεσα μηνύματα", diff --git a/web/messages/en-US.json b/web/messages/en-US.json index 06ce3a241..93d316de2 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Channels", "homeTabMixedTimelineTitle": "Mixed", "homeTabSocialTimelineTitle": "Social", + "illustrationsTitle": "Illustrations", + "mangaTitle": "Manga", "mastodonTabLocalTitle": "Local", "mastodonTabPublicTitle": "Public", "dmListTitle": "Direct Messages", diff --git a/web/messages/en.json b/web/messages/en.json index 4ee3fb737..a01fef503 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Channels", "homeTabMixedTimelineTitle": "Mixed", "homeTabSocialTimelineTitle": "Social", + "illustrationsTitle": "Illustrations", + "mangaTitle": "Manga", "mastodonTabLocalTitle": "Local", "mastodonTabPublicTitle": "Public", "dmListTitle": "Direct Messages", diff --git a/web/messages/es-ES.json b/web/messages/es-ES.json index d9394d827..a71aaf907 100644 --- a/web/messages/es-ES.json +++ b/web/messages/es-ES.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Canales", "homeTabMixedTimelineTitle": "Mixto", "homeTabSocialTimelineTitle": "Social", + "illustrationsTitle": "Illustraciones", + "mangaTitle": "Manga", "mastodonTabLocalTitle": "Local", "mastodonTabPublicTitle": "Público", "dmListTitle": "Mensajes directos", diff --git a/web/messages/fi-FI.json b/web/messages/fi-FI.json index 13d11b537..70f2e21d6 100644 --- a/web/messages/fi-FI.json +++ b/web/messages/fi-FI.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Kanavat", "homeTabMixedTimelineTitle": "Sekoitus", "homeTabSocialTimelineTitle": "Sosiaalinen", + "illustrationsTitle": "Kuvitukset", + "mangaTitle": "Manga", "mastodonTabLocalTitle": "Paikallinen", "mastodonTabPublicTitle": "Julkinen", "dmListTitle": "Suoraviestit", diff --git a/web/messages/fr-FR.json b/web/messages/fr-FR.json index f6d729bed..2bb0a6956 100644 --- a/web/messages/fr-FR.json +++ b/web/messages/fr-FR.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Canaux", "homeTabMixedTimelineTitle": "Mixte", "homeTabSocialTimelineTitle": "Social", + "illustrationsTitle": "Illustrations", + "mangaTitle": "Manga", "mastodonTabLocalTitle": "Local", "mastodonTabPublicTitle": "Public", "dmListTitle": "Messages directs", diff --git a/web/messages/he-IL.json b/web/messages/he-IL.json index 8c9b790b9..17b248e00 100644 --- a/web/messages/he-IL.json +++ b/web/messages/he-IL.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "ערוצים", "homeTabMixedTimelineTitle": "מעורב", "homeTabSocialTimelineTitle": "חברתי", + "illustrationsTitle": "איורים", + "mangaTitle": "מנגה", "mastodonTabLocalTitle": "מקומי", "mastodonTabPublicTitle": "ציבורי", "dmListTitle": "הודעות ישירות", diff --git a/web/messages/hu-HU.json b/web/messages/hu-HU.json index d85ba35c6..d091664ff 100644 --- a/web/messages/hu-HU.json +++ b/web/messages/hu-HU.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Csatornák", "homeTabMixedTimelineTitle": "Vegyes", "homeTabSocialTimelineTitle": "Közösségi", + "illustrationsTitle": "Illusztrációk", + "mangaTitle": "Manga", "mastodonTabLocalTitle": "Helyi", "mastodonTabPublicTitle": "Nyilvános", "dmListTitle": "Közvetlen üzenetek", diff --git a/web/messages/it-IT.json b/web/messages/it-IT.json index ee5217c0c..abb1ab3ee 100644 --- a/web/messages/it-IT.json +++ b/web/messages/it-IT.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Canali", "homeTabMixedTimelineTitle": "Misto", "homeTabSocialTimelineTitle": "Social", + "illustrationsTitle": "Illustrazioni", + "mangaTitle": "Manga", "mastodonTabLocalTitle": "Locale", "mastodonTabPublicTitle": "Pubblico", "dmListTitle": "Messaggi diretti", diff --git a/web/messages/ja-JP.json b/web/messages/ja-JP.json index 066a5b30e..d2b33dea6 100644 --- a/web/messages/ja-JP.json +++ b/web/messages/ja-JP.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "チャンネル", "homeTabMixedTimelineTitle": "混合", "homeTabSocialTimelineTitle": "ソーシャル", + "illustrationsTitle": "イラスト", + "mangaTitle": "マンガ", "mastodonTabLocalTitle": "ローカル", "mastodonTabPublicTitle": "連合", "dmListTitle": "メッセージ", diff --git a/web/messages/ko-KR.json b/web/messages/ko-KR.json index 04a6dcabe..751152509 100644 --- a/web/messages/ko-KR.json +++ b/web/messages/ko-KR.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "채널", "homeTabMixedTimelineTitle": "혼합", "homeTabSocialTimelineTitle": "소셜", + "illustrationsTitle": "일러스트", + "mangaTitle": "만화", "mastodonTabLocalTitle": "로컬", "mastodonTabPublicTitle": "공개", "dmListTitle": "다이렉트 메시지", diff --git a/web/messages/nl-NL.json b/web/messages/nl-NL.json index 4e55b5252..912b2b53e 100644 --- a/web/messages/nl-NL.json +++ b/web/messages/nl-NL.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Kanalen", "homeTabMixedTimelineTitle": "Gemengd", "homeTabSocialTimelineTitle": "Sociaal", + "illustrationsTitle": "Illustraties", + "mangaTitle": "Manga", "mastodonTabLocalTitle": "Lokaal", "mastodonTabPublicTitle": "Openbaar", "dmListTitle": "Directe berichten", diff --git a/web/messages/no-NO.json b/web/messages/no-NO.json index 99e695e44..8d1f6d544 100644 --- a/web/messages/no-NO.json +++ b/web/messages/no-NO.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Kanaler", "homeTabMixedTimelineTitle": "Blandet", "homeTabSocialTimelineTitle": "Sosialt", + "illustrationsTitle": "Illustrasjoner", + "mangaTitle": "Manga", "mastodonTabLocalTitle": "Lokalt", "mastodonTabPublicTitle": "Offentlig", "dmListTitle": "Direktemeldinger", diff --git a/web/messages/pl-PL.json b/web/messages/pl-PL.json index 6506f26a6..63be700a0 100644 --- a/web/messages/pl-PL.json +++ b/web/messages/pl-PL.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Kanały", "homeTabMixedTimelineTitle": "Mieszana", "homeTabSocialTimelineTitle": "Społeczność", + "illustrationsTitle": "Ilustracje", + "mangaTitle": "Manga", "mastodonTabLocalTitle": "Lokalne", "mastodonTabPublicTitle": "Publiczne", "dmListTitle": "Wiadomości bezpośrednie", diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index a04916844..c790320ee 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Canais", "homeTabMixedTimelineTitle": "Misto", "homeTabSocialTimelineTitle": "Social", + "illustrationsTitle": "Ilustrações", + "mangaTitle": "Manga", "mastodonTabLocalTitle": "Local", "mastodonTabPublicTitle": "Público", "dmListTitle": "Mensagens Diretas", diff --git a/web/messages/pt-PT.json b/web/messages/pt-PT.json index e76f55e1c..de08e1c15 100644 --- a/web/messages/pt-PT.json +++ b/web/messages/pt-PT.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Canais", "homeTabMixedTimelineTitle": "Misto", "homeTabSocialTimelineTitle": "Social", + "illustrationsTitle": "Ilustrações", + "mangaTitle": "Manga", "mastodonTabLocalTitle": "Local", "mastodonTabPublicTitle": "Público", "dmListTitle": "Mensagens Diretas", diff --git a/web/messages/ro-RO.json b/web/messages/ro-RO.json index dcef9ffd5..52f52513c 100644 --- a/web/messages/ro-RO.json +++ b/web/messages/ro-RO.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Canale", "homeTabMixedTimelineTitle": "Mixt", "homeTabSocialTimelineTitle": "Social", + "illustrationsTitle": "Ilustraţii", + "mangaTitle": "Manga", "mastodonTabLocalTitle": "Local", "mastodonTabPublicTitle": "Public", "dmListTitle": "Mesaje Directe", diff --git a/web/messages/ru-RU.json b/web/messages/ru-RU.json index b1cadd612..93cc3abdd 100644 --- a/web/messages/ru-RU.json +++ b/web/messages/ru-RU.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Каналы", "homeTabMixedTimelineTitle": "Смешанная", "homeTabSocialTimelineTitle": "Социальная", + "illustrationsTitle": "Иллюстрации", + "mangaTitle": "Манга", "mastodonTabLocalTitle": "Локальная", "mastodonTabPublicTitle": "Публичная", "dmListTitle": "Личные сообщения", diff --git a/web/messages/sr-SP.json b/web/messages/sr-SP.json index ad6623946..92d34f1fb 100644 --- a/web/messages/sr-SP.json +++ b/web/messages/sr-SP.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Канали", "homeTabMixedTimelineTitle": "Мешовито", "homeTabSocialTimelineTitle": "Друштвено", + "illustrationsTitle": "Илустрације", + "mangaTitle": "Манга", "mastodonTabLocalTitle": "Lokalno", "mastodonTabPublicTitle": "Javno", "dmListTitle": "Direktne poruke", diff --git a/web/messages/sv-SE.json b/web/messages/sv-SE.json index 63b1ba2fd..5243ef31b 100644 --- a/web/messages/sv-SE.json +++ b/web/messages/sv-SE.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Kanaler", "homeTabMixedTimelineTitle": "Blandat", "homeTabSocialTimelineTitle": "Socialt", + "illustrationsTitle": "Illustrationer", + "mangaTitle": "Manga", "mastodonTabLocalTitle": "Lokalt", "mastodonTabPublicTitle": "Offentligt", "dmListTitle": "Direktmeddelanden", diff --git a/web/messages/tr-TR.json b/web/messages/tr-TR.json index c1e72595f..8dcf69d4f 100644 --- a/web/messages/tr-TR.json +++ b/web/messages/tr-TR.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Kanallar", "homeTabMixedTimelineTitle": "Karışık", "homeTabSocialTimelineTitle": "Sosyal", + "illustrationsTitle": "İllüstrasyonlar", + "mangaTitle": "Manga", "mastodonTabLocalTitle": "Yerel", "mastodonTabPublicTitle": "Genel", "dmListTitle": "Doğrudan Mesajlar", diff --git a/web/messages/uk-UA.json b/web/messages/uk-UA.json index 844f5f8fd..e374151c5 100644 --- a/web/messages/uk-UA.json +++ b/web/messages/uk-UA.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Канали", "homeTabMixedTimelineTitle": "Змішана", "homeTabSocialTimelineTitle": "Соціальна", + "illustrationsTitle": "Ілюстрації", + "mangaTitle": "Манґа", "mastodonTabLocalTitle": "Локальна", "mastodonTabPublicTitle": "Публічна", "dmListTitle": "Прямі повідомлення", diff --git a/web/messages/vi-VN.json b/web/messages/vi-VN.json index b43354a3d..ac71c8e89 100644 --- a/web/messages/vi-VN.json +++ b/web/messages/vi-VN.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "Kênh", "homeTabMixedTimelineTitle": "Hỗn hợp", "homeTabSocialTimelineTitle": "Mạng xã hội", + "illustrationsTitle": "Minh họa", + "mangaTitle": "Manga", "mastodonTabLocalTitle": "Cục bộ", "mastodonTabPublicTitle": "Công khai", "dmListTitle": "Tin nhắn trực tiếp", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 6c4c26de1..6e2649b29 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "频道", "homeTabMixedTimelineTitle": "混合", "homeTabSocialTimelineTitle": "社交", + "illustrationsTitle": "插画", + "mangaTitle": "漫画", "mastodonTabLocalTitle": "本地", "mastodonTabPublicTitle": "公共", "dmListTitle": "私信", diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index d129b9415..cc54d2716 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -64,6 +64,8 @@ "homeTabChannelsTitle": "頻道", "homeTabMixedTimelineTitle": "混合", "homeTabSocialTimelineTitle": "社交", + "illustrationsTitle": "插畫", + "mangaTitle": "漫畫", "mastodonTabLocalTitle": "本地", "mastodonTabPublicTitle": "跨站", "dmListTitle": "私訊", diff --git a/web/src/lib/i18n/uiStrings.ts b/web/src/lib/i18n/uiStrings.ts index cfd5232e8..6b5f9dbbd 100644 --- a/web/src/lib/i18n/uiStrings.ts +++ b/web/src/lib/i18n/uiStrings.ts @@ -94,9 +94,9 @@ export function localizedUiString(value: UiStrings): string { case 'PixivRankingDayManga': return 'Manga Ranking'; case 'Illustrations': - return 'Illustrations'; + return m.illustrationsTitle(); case 'Manga': - return 'Manga'; + return m.mangaTitle(); case 'FanboxSupported': return 'Supported posts'; case 'FanboxRecommendedCreators':