diff --git a/app/src/main/java/dev/dimension/flare/ui/component/SearchBar.kt b/app/src/main/java/dev/dimension/flare/ui/component/SearchBar.kt index 32a6a4a80..7e389df97 100644 --- a/app/src/main/java/dev/dimension/flare/ui/component/SearchBar.kt +++ b/app/src/main/java/dev/dimension/flare/ui/component/SearchBar.kt @@ -50,7 +50,7 @@ import dev.dimension.flare.ui.component.status.status import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiSearchHistory import dev.dimension.flare.ui.model.UiState -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.home.SearchHistoryPresenter import dev.dimension.flare.ui.presenter.home.SearchHistoryState @@ -166,7 +166,7 @@ private fun SearchContent( internal fun LazyStaggeredGridScope.searchContent( searchUsers: PagingState, - searchStatus: PagingState, + searchStatus: PagingState, toUser: (MicroBlogKey) -> Unit, ) { searchUsers 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 482cf3bb0..04243bcb1 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 @@ -448,12 +448,24 @@ internal sealed interface Route : NavKey { val userKey: MicroBlogKey, ) : Route + @Serializable + public data class UnblockUser( + val accountType: AccountType?, + val userKey: MicroBlogKey, + ) : Route + @Serializable public data class MuteUser( val accountType: AccountType?, val userKey: MicroBlogKey, ) : Route + @Serializable + public data class UnmuteUser( + val accountType: AccountType?, + val userKey: MicroBlogKey, + ) : Route + @Serializable public data class ReportUser( val accountType: AccountType?, @@ -625,6 +637,11 @@ internal sealed interface Route : NavKey { accountType = deeplinkRoute.accountKey?.let { AccountType.Specific(it) }, userKey = deeplinkRoute.userKey, ) + is DeeplinkRoute.UnblockUser -> + Route.UnblockUser( + accountType = deeplinkRoute.accountKey?.let { AccountType.Specific(it) }, + userKey = deeplinkRoute.userKey, + ) is DeeplinkRoute.DirectMessage -> DM.UserConversation( accountType = AccountType.Specific(deeplinkRoute.accountKey), @@ -640,6 +657,11 @@ internal sealed interface Route : NavKey { accountType = deeplinkRoute.accountKey?.let { AccountType.Specific(it) }, userKey = deeplinkRoute.userKey, ) + is DeeplinkRoute.UnmuteUser -> + Route.UnmuteUser( + accountType = deeplinkRoute.accountKey?.let { AccountType.Specific(it) }, + userKey = deeplinkRoute.userKey, + ) is DeeplinkRoute.ReportUser -> Route.ReportUser( accountType = deeplinkRoute.accountKey?.let { AccountType.Specific(it) }, diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt index d7f5ab79a..aa5ec5c46 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt @@ -105,7 +105,7 @@ import dev.dimension.flare.ui.component.status.CommonStatusComponent import dev.dimension.flare.ui.component.status.StatusVisibilityComponent import dev.dimension.flare.ui.model.UiEmoji import dev.dimension.flare.ui.model.UiState -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.model.map import dev.dimension.flare.ui.model.mapNotNull import dev.dimension.flare.ui.model.onError @@ -113,6 +113,7 @@ import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.model.takeSuccess import dev.dimension.flare.ui.presenter.compose.ComposePresenter import dev.dimension.flare.ui.presenter.compose.ComposeStatus +import dev.dimension.flare.ui.presenter.home.ActiveAccountPresenter import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.theme.FlareTheme import dev.dimension.flare.ui.theme.screenHorizontalPadding @@ -132,13 +133,21 @@ fun ShortcutComposeRoute( initialText: String = "", initialMedias: ImmutableList = persistentListOf(), ) { + val activeAccountState by producePresenter(key = "shortcut_compose_active_account") { + activeAccountPresenter() + } + val accountType = + activeAccountState.user + .takeSuccess() + ?.let { AccountType.Specific(it.key) } + ?: AccountType.Guest FlareTheme { CompositionLocalProvider( LocalContentColor provides MaterialTheme.colorScheme.onBackground, ) { ComposeScreen( onBack = onBack, - accountType = AccountType.Active, + accountType = accountType, initialText = initialText, initialMedias = initialMedias, ) @@ -146,6 +155,12 @@ fun ShortcutComposeRoute( } } +@Composable +private fun activeAccountPresenter() = + run { + remember { ActiveAccountPresenter() }.invoke() + } + @OptIn( ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class, @@ -255,7 +270,7 @@ internal fun ComposeScreen( state.state.selectAccount(account) }, label = { - Text(it.handle) + Text(it.handle.canonical) }, leadingIcon = { AvatarComponent(it.avatar, size = 24.dp) @@ -285,7 +300,7 @@ internal fun ComposeScreen( user.onSuccess { data -> DropdownMenuItem( text = { - Text(text = data.handle) + Text(text = data.handle.canonical) }, onClick = { state.state.selectAccount(account) @@ -616,8 +631,8 @@ internal fun ComposeScreen( state.state.replyState?.let { replyState -> replyState.onSuccess { state -> - val content = state.content - if (content is UiTimeline.ItemContent.Status) { + val content = state as? UiTimelineV2.Post + if (content is UiTimelineV2.Post) { Card { CompositionLocalProvider( LocalComponentAppearance provides @@ -1062,7 +1077,7 @@ private fun composePresenter( ?.toString(), visibility = state.visibilityState.takeSuccess()?.visibility - ?: UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Public, + ?: UiTimelineV2.Post.Visibility.Public, account = it, referenceStatus = status?.let { @@ -1286,40 +1301,40 @@ internal enum class PollExpiration( Days7(R.string.compose_poll_expiration_7_days, 7.days), } -internal val UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.localName: Int +internal val UiTimelineV2.Post.Visibility.localName: Int get() = when (this) { - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Public -> + UiTimelineV2.Post.Visibility.Public -> R.string.misskey_visibility_public - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Home -> + UiTimelineV2.Post.Visibility.Home -> R.string.misskey_visibility_home - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Followers -> + UiTimelineV2.Post.Visibility.Followers -> R.string.misskey_visibility_followers - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Specified -> + UiTimelineV2.Post.Visibility.Specified -> R.string.misskey_visibility_specified - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Channel -> + UiTimelineV2.Post.Visibility.Channel -> R.string.misskey_visibility_public } -internal val UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.localDescription: Int +internal val UiTimelineV2.Post.Visibility.localDescription: Int get() = when (this) { - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Public -> + UiTimelineV2.Post.Visibility.Public -> R.string.misskey_visibility_public_description - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Home -> + UiTimelineV2.Post.Visibility.Home -> R.string.misskey_visibility_home_description - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Followers -> + UiTimelineV2.Post.Visibility.Followers -> R.string.misskey_visibility_followers_description - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Specified -> + UiTimelineV2.Post.Visibility.Specified -> R.string.misskey_visibility_specified_description - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Channel -> + UiTimelineV2.Post.Visibility.Channel -> R.string.misskey_visibility_public_description } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt index a5bf11725..4549624db 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt @@ -118,7 +118,7 @@ internal fun DiscoverScreen(onUserClick: (AccountType, MicroBlogKey) -> Unit) { state.setAccount(profile) }, label = { - Text(profile.handle) + Text(profile.handle.canonical) }, leadingIcon = { AvatarComponent( diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt index 3c4a311d4..4a5b84944 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt @@ -89,6 +89,7 @@ import dev.dimension.flare.ui.model.onLoading import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.model.takeSuccess import dev.dimension.flare.ui.presenter.HomeTabsPresenter +import dev.dimension.flare.ui.presenter.home.ActiveAccountPresenter import dev.dimension.flare.ui.presenter.home.AllNotificationBadgePresenter import dev.dimension.flare.ui.presenter.home.UserPresenter import dev.dimension.flare.ui.presenter.invoke @@ -136,7 +137,7 @@ internal fun HomeScreen(afterInit: () -> Unit) { } val currentRoute = topLevelBackStack.topLevelKey - val accountType = currentRoute.accountTypeOr(AccountType.Active) + val accountType = currentRoute.accountTypeOr(state.defaultAccountType) val userState by producePresenter(key = "home_account_type_$accountType") { userPresenter(accountType) } @@ -167,6 +168,7 @@ internal fun HomeScreen(afterInit: () -> Unit) { userState, layoutType, currentRoute, + state.defaultAccountType, navigate, ) }, @@ -314,6 +316,7 @@ private fun HomeRailHeader( userState: UiState, layoutType: NavigationSuiteType, currentRoute: Route, + defaultAccountType: AccountType, navigate: (Route) -> Unit, ) { val scope = rememberCoroutineScope() @@ -418,7 +421,7 @@ private fun HomeRailHeader( textStyle = MaterialTheme.typography.titleMedium, ) Text( - user.handle, + user.handle.canonical, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline, ) @@ -456,7 +459,7 @@ private fun HomeRailHeader( navigate( Route.Compose.New( currentRoute.accountTypeOr( - AccountType.Active, + defaultAccountType, ), ), ) @@ -484,7 +487,7 @@ private fun HomeRailHeader( navigate( Route.Compose.New( currentRoute.accountTypeOr( - AccountType.Active, + defaultAccountType, ), ), ) @@ -542,6 +545,7 @@ private fun getDirection( @Composable private fun presenter() = run { + val activeAccountState = remember { ActiveAccountPresenter() }.invoke() val navigationState = remember { NavigationState() @@ -563,6 +567,11 @@ private fun presenter() = val tabs = tabs.tabs val navigationState = navigationState val scrollToTopRegistry = scrollToTopRegistry + val defaultAccountType: AccountType = + activeAccountState.user + .takeSuccess() + ?.let { AccountType.Specific(it.key) } + ?: AccountType.Guest } } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/NotificationScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/NotificationScreen.kt index 89b5d0f0a..678ba5a8e 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/NotificationScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/NotificationScreen.kt @@ -93,7 +93,9 @@ internal fun NotificationScreen() { }, minTabWidth = 48.dp, ) { - state.notifications.forEach { (account, badge) -> + state.notifications.forEach { item -> + val account = item.profile + val badge = item.badge LeadingIconTab( modifier = Modifier.clip(CircleShape), selectedContentColor = MaterialTheme.colorScheme.onSecondaryContainer, @@ -104,7 +106,7 @@ internal fun NotificationScreen() { }, text = { Text( - text = account.handle, + text = account.handle.canonical, maxLines = 1, ) }, diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/SearchScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/SearchScreen.kt index 7f330c04e..f222b864b 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/SearchScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/SearchScreen.kt @@ -92,7 +92,7 @@ internal fun SearchScreen( state.searchState.setAccount(profile) }, label = { - Text(profile.handle) + Text(profile.handle.canonical) }, leadingIcon = { AvatarComponent( 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 b3aa382c5..37602b0ab 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 @@ -110,7 +110,7 @@ import dev.dimension.flare.ui.component.status.CommonStatusComponent import dev.dimension.flare.ui.humanizer.humanize import dev.dimension.flare.ui.model.UiMedia import dev.dimension.flare.ui.model.UiState -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.model.getFileName import dev.dimension.flare.ui.model.isSuccess import dev.dimension.flare.ui.model.onLoading @@ -438,8 +438,8 @@ internal fun StatusMediaScreen( } state.status.onSuccess { status -> - val content = status.content - if (content is UiTimeline.ItemContent.Status) { + val content = status as? UiTimelineV2.Post + if (content is UiTimelineV2.Post) { androidx.compose.animation.AnimatedVisibility( visible = state.showUi, modifier = @@ -563,8 +563,8 @@ internal fun StatusMediaScreen( contentColor = MaterialTheme.colorScheme.onSurface, ) { state.status.onSuccess { - val content = it.content - if (content is UiTimeline.ItemContent.Status) { + val content = it as? UiTimelineV2.Post + if (content is UiTimelineV2.Post) { CompositionLocalProvider( LocalComponentAppearance provides LocalComponentAppearance.current.copy( @@ -866,7 +866,7 @@ private fun statusMediaPresenter( .onSuccess { medias = UiState.Success( - (it.content as? UiTimeline.ItemContent.Status) + (it as? UiTimelineV2.Post) ?.images .orEmpty() .toImmutableList(), @@ -904,11 +904,11 @@ private fun statusMediaPresenter( } fun save(data: UiMedia) { - val status = (state.status.takeSuccess()?.content as? UiTimeline.ItemContent.Status) + val status = (state.status.takeSuccess() as? UiTimelineV2.Post) if (status != null) { - val statusKey = status.statusKey.toString() - val userHandle = status.user?.handle ?: "unknown" - val fileName = data.getFileName(statusKey, userHandle) + 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) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/profile/BlockUserDialog.kt b/app/src/main/java/dev/dimension/flare/ui/screen/profile/BlockUserDialog.kt index a2abdc32e..00fcf9ce3 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/profile/BlockUserDialog.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/profile/BlockUserDialog.kt @@ -12,6 +12,8 @@ import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.presenter.profile.BlockUserPresenter import dev.dimension.flare.ui.presenter.profile.MuteUserPresenter +import dev.dimension.flare.ui.presenter.profile.UnblockUserPresenter +import dev.dimension.flare.ui.presenter.profile.UnmuteUserPresenter import moe.tlaster.precompose.molecule.producePresenter @Composable @@ -88,6 +90,80 @@ internal fun MuteUserDialog( ) } +@Composable +internal fun UnblockUserDialog( + accountType: AccountType?, + userKey: MicroBlogKey, + onBack: () -> Unit, +) { + val state by producePresenter("unblock_user_${accountType}_$userKey") { + remember { + UnblockUserPresenter(accountType, userKey) + }.body() + } + AlertDialog( + onDismissRequest = onBack, + title = { + Text(text = stringResource(id = R.string.unblock_user_title)) + }, + text = { + Text(text = stringResource(id = R.string.unblock_user_description)) + }, + confirmButton = { + TextButton( + onClick = { + state.confirm() + onBack.invoke() + }, + ) { + Text(text = stringResource(id = android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onBack) { + Text(text = stringResource(id = android.R.string.cancel)) + } + }, + ) +} + +@Composable +internal fun UnmuteUserDialog( + accountType: AccountType?, + userKey: MicroBlogKey, + onBack: () -> Unit, +) { + val state by producePresenter("unmute_user_${accountType}_$userKey") { + remember { + UnmuteUserPresenter(accountType, userKey) + }.body() + } + AlertDialog( + onDismissRequest = onBack, + title = { + Text(text = stringResource(id = R.string.unmute_user_title)) + }, + text = { + Text(text = stringResource(id = R.string.unmute_user_description)) + }, + confirmButton = { + TextButton( + onClick = { + state.confirm() + onBack.invoke() + }, + ) { + Text(text = stringResource(id = android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onBack) { + Text(text = stringResource(id = android.R.string.cancel)) + } + }, + ) +} + @Composable internal fun ReportUserDialog( accountType: AccountType?, diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileEntryBuilder.kt b/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileEntryBuilder.kt index 5fd881c7b..d86b5008b 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileEntryBuilder.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileEntryBuilder.kt @@ -232,6 +232,26 @@ internal fun EntryProviderScope.profileEntryBuilder( ) } + entry( + metadata = DialogSceneStrategy.dialog() + ) { + UnblockUserDialog( + accountType = it.accountType, + userKey = it.userKey, + onBack = onBack, + ) + } + + entry( + metadata = DialogSceneStrategy.dialog() + ) { + UnmuteUserDialog( + accountType = it.accountType, + userKey = it.userKey, + onBack = onBack, + ) + } + entry( metadata = DialogSceneStrategy.dialog() ) { @@ -243,4 +263,4 @@ internal fun EntryProviderScope.profileEntryBuilder( } -} \ No newline at end of file +} diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileScreen.kt index ff3e6fd7a..6a8a9097b 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileScreen.kt @@ -82,7 +82,7 @@ import dev.dimension.flare.ui.component.status.MediaItem import dev.dimension.flare.ui.component.status.StatusPlaceholder import dev.dimension.flare.ui.component.status.status import dev.dimension.flare.ui.model.UiMedia -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.model.map import dev.dimension.flare.ui.model.onError import dev.dimension.flare.ui.model.onLoading @@ -564,10 +564,10 @@ private fun ProfileMediaTab( .clip(MaterialTheme.shapes.medium) .clipToBounds() .clickable { - val content = item.status.content - if (content is UiTimeline.ItemContent.Status) { + val content = item.status + if (content is UiTimelineV2.Post) { onItemClicked( - content.statusKey, + item.statusKey, item.index, when (media) { is UiMedia.Image -> media.previewUrl @@ -702,7 +702,7 @@ private fun profilePresenter( private sealed interface ProfileTabItem { data class Timeline( val type: ProfileTab.Timeline.Type, - val data: PagingState, + val data: PagingState, ) : ProfileTabItem data class Media( diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/AccountsScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/AccountsScreen.kt index b65e84e6f..141420830 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/AccountsScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/AccountsScreen.kt @@ -242,7 +242,7 @@ fun AccountItem( RichText(text = it.name, maxLines = 1) }, supportingContent: @Composable (UiProfile) -> Unit = { - Text(text = it.handle, maxLines = 1) + Text(text = it.handle.canonical, maxLines = 1) }, avatarSize: Dp = AvatarComponentDefaults.size, colors: ListItemColors = ListItemDefaults.segmentedColors(), diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/TabAddBottomSheet.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/TabAddBottomSheet.kt index 7167bc5d0..a0e4929ac 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/TabAddBottomSheet.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/TabAddBottomSheet.kt @@ -152,7 +152,7 @@ internal fun TabAddBottomSheet( overflow = TextOverflow.Ellipsis, ) Text( - text = tab.profile.handle, + text = tab.profile.handle.canonical, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline, maxLines = 1, @@ -369,7 +369,8 @@ internal fun allTabsPresenter(filterIsTimeline: Boolean = false): AllTabsState = object : AllTabsState { override val defaultTabs = - TimelineTabItem.mainSidePanel + TimelineTabItem + .mainSidePanel(null) .let { if (filterIsTimeline) { it.filterIsInstance() diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/status/StatusScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/status/StatusScreen.kt index 5dd845152..2943c681a 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/status/StatusScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/status/StatusScreen.kt @@ -30,7 +30,6 @@ import dev.dimension.flare.ui.component.RefreshContainer import dev.dimension.flare.ui.component.platform.isBigScreen import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid import dev.dimension.flare.ui.component.status.status -import dev.dimension.flare.ui.model.takeSuccess import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.status.StatusContextPresenter import kotlinx.coroutines.launch @@ -91,10 +90,7 @@ internal fun StatusScreen( ) { status( state.state.listState, - detailStatusKey = - state.state.current - .takeSuccess() - ?.statusKey, + detailStatusKey = statusKey, ) } }, diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt index a01c36ae0..8db9a0490 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt @@ -53,7 +53,7 @@ import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid import dev.dimension.flare.ui.component.status.StatusItem import dev.dimension.flare.ui.component.status.status import dev.dimension.flare.ui.model.UiState -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.model.onError import dev.dimension.flare.ui.model.onLoading import dev.dimension.flare.ui.model.onSuccess @@ -156,13 +156,13 @@ internal fun VVOStatusScreen( @Composable private fun StatusContent( - statusState: UiState, + statusState: UiState, detailStatusKey: MicroBlogKey, modifier: Modifier = Modifier, ) { statusState .onSuccess { status -> - key(status.itemKey, status.content) { + key(status.itemKey) { StatusItem( item = status, detailStatusKey = detailStatusKey, @@ -207,8 +207,8 @@ private fun StatusContent( @OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun LazyStaggeredGridScope.reactionContent( - comment: PagingState, - repost: PagingState, + comment: PagingState, + repost: PagingState, detailType: DetailType, onDetailTypeChange: (DetailType) -> Unit, ) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9b39b71e5..79317f540 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -410,9 +410,13 @@ Block user Are you sure you want to block this user? + Unblock user + Are you sure you want to unblock this user? Mute user Are you sure you want to mute this user? + Unmute user + Are you sure you want to unmute this user? Report user Are you sure you want to report this user? diff --git a/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.android.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.android.kt index 93ff1b967..c99769f7a 100644 --- a/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.android.kt +++ b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.android.kt @@ -1,6 +1,7 @@ package dev.dimension.flare.ui.component.platform import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -50,6 +51,41 @@ internal actual fun PlatformFilledTonalButton( ) } +@Composable +internal actual fun PlatformOutlinedButton( + onClick: () -> Unit, + modifier: Modifier, + enabled: Boolean, + content: @Composable RowScope.() -> Unit, +) { + androidx.compose.material3.OutlinedButton( + onClick = onClick, + modifier = modifier, + content = content, + enabled = enabled, + ) +} + +@Composable +internal actual fun PlatformErrorButton( + onClick: () -> Unit, + modifier: Modifier, + enabled: Boolean, + content: @Composable RowScope.() -> Unit, +) { + androidx.compose.material3.FilledTonalButton( + onClick = onClick, + modifier = modifier, + content = content, + enabled = enabled, + colors = + androidx.compose.material3.ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) +} + @Composable internal actual fun PlatformIconButton( onClick: () -> Unit, diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt index d102f8525..1bd2e933d 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt @@ -162,7 +162,7 @@ public data object AllNotificationTabItem : TabItem() { title = TitleType.Localized(TitleType.Localized.LocalizedKey.Notifications), icon = IconType.Material(IconType.Material.MaterialIcon.Notification), ) - override val account: AccountType = AccountType.Active + override val account: AccountType = AccountType.Guest override val key: String = "all_notification" override fun update(metaData: TabMetaData): TabItem = this @@ -186,53 +186,75 @@ public sealed class TimelineTabItem : TabItem() { public abstract fun createPresenter(): TimelinePresenter public companion object { - public val default: ImmutableList = - persistentListOf( - HomeTimelineTabItem( - account = AccountType.Active, - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Home), - icon = IconType.Material(IconType.Material.MaterialIcon.Home), - ), - ), - AllNotificationTabItem, - DiscoverTabItem( - account = AccountType.Active, - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Discover), - icon = IconType.Material(IconType.Material.MaterialIcon.Search), - ), - ), - ) - public val mainSidePanel: ImmutableList = - persistentListOf( - HomeTimelineTabItem( - account = AccountType.Active, - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Home), - icon = IconType.Material(IconType.Material.MaterialIcon.Home), - ), - ), - AllNotificationTabItem, - RssTabItem( - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Rss), - icon = IconType.Material(IconType.Material.MaterialIcon.Rss), - ), - ), - DiscoverTabItem( - account = AccountType.Active, - metaData = - TabMetaData( - title = TitleType.Localized(TitleType.Localized.LocalizedKey.Discover), - icon = IconType.Material(IconType.Material.MaterialIcon.Search), - ), - ), - ) + public fun default(accountKey: MicroBlogKey?): ImmutableList = + accountKey?.let { + val accountType = AccountType.Specific(it) + persistentListOf( + HomeTimelineTabItem( + account = accountType, + metaData = + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.Home), + icon = IconType.Material(IconType.Material.MaterialIcon.Home), + ), + ), + NotificationTabItem( + account = accountType, + metaData = + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.Notifications), + icon = IconType.Material(IconType.Material.MaterialIcon.Notification), + ), + ), + DiscoverTabItem( + account = accountType, + metaData = + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.Discover), + icon = IconType.Material(IconType.Material.MaterialIcon.Search), + ), + ), + ) + } ?: guest + + public fun mainSidePanel(accountKey: MicroBlogKey?): ImmutableList = + accountKey?.let { + val accountType = AccountType.Specific(it) + persistentListOf( + HomeTimelineTabItem( + account = accountType, + metaData = + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.Home), + icon = IconType.Material(IconType.Material.MaterialIcon.Home), + ), + ), + NotificationTabItem( + account = accountType, + metaData = + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.Notifications), + icon = IconType.Material(IconType.Material.MaterialIcon.Notification), + ), + ), + RssTabItem( + metaData = + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.Rss), + icon = IconType.Material(IconType.Material.MaterialIcon.Rss), + ), + ), + DiscoverTabItem( + account = accountType, + metaData = + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.Discover), + icon = IconType.Material(IconType.Material.MaterialIcon.Search), + ), + ), + ) + } ?: guest + public val guest: ImmutableList = persistentListOf( HomeTimelineTabItem( @@ -1121,7 +1143,7 @@ public data class AllRssTimelineTabItem( title = TitleType.Localized(TitleType.Localized.LocalizedKey.AllRssFeeds), icon = IconType.Material(IconType.Material.MaterialIcon.Rss), ), - override val account: AccountType = AccountType.Active, + override val account: AccountType = AccountType.Guest, ) : TimelineTabItem() { override val key: String = "all_rss" @@ -1171,7 +1193,7 @@ public data class DiscoverTabItem( @Serializable public data object SettingsTabItem : TabItem() { override val account: AccountType - get() = AccountType.Active + get() = AccountType.Guest override val key: String get() = "settings" override val metaData: TabMetaData @@ -1199,7 +1221,7 @@ public data class DirectMessageTabItem( @Serializable public data class RssTabItem( override val metaData: TabMetaData, - override val account: AccountType = AccountType.Active, + override val account: AccountType = AccountType.Guest, ) : TabItem() { override val key: String = "rss" diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/CommonProfileHeader.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/CommonProfileHeader.kt index 6ae7c8bdc..5066c2641 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/CommonProfileHeader.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/CommonProfileHeader.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.unit.dp import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.component.platform.PlatformText import dev.dimension.flare.ui.component.platform.isBigScreen +import dev.dimension.flare.ui.model.UiHandle import dev.dimension.flare.ui.render.UiRichText import dev.dimension.flare.ui.theme.PlatformTheme import dev.dimension.flare.ui.theme.screenHorizontalPadding @@ -35,7 +36,7 @@ internal fun CommonProfileHeader( avatarUrl: String?, displayName: UiRichText, userKey: MicroBlogKey, - handle: String, + handle: UiHandle, modifier: Modifier = Modifier, onAvatarClick: (() -> Unit)? = null, onBannerClick: (() -> Unit)? = null, @@ -190,7 +191,7 @@ internal fun CommonProfileHeader( verticalAlignment = Alignment.CenterVertically, ) { PlatformText( - text = handle, + text = handle.canonical, style = PlatformTheme.typography.caption, // modifier = // Modifier diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ProfileHeader.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ProfileHeader.kt index a80a581c9..b5ee3a421 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ProfileHeader.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ProfileHeader.kt @@ -1,5 +1,11 @@ package dev.dimension.flare.ui.component +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -41,13 +47,16 @@ import dev.dimension.flare.compose.ui.profile_header_button_is_fans import dev.dimension.flare.compose.ui.profile_header_button_requested import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.component.placeholder +import dev.dimension.flare.ui.component.platform.PlatformErrorButton import dev.dimension.flare.ui.component.platform.PlatformFilledTonalButton +import dev.dimension.flare.ui.component.platform.PlatformOutlinedButton import dev.dimension.flare.ui.component.platform.PlatformText import dev.dimension.flare.ui.component.platform.isBigScreen import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiRelation import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.model.takeSuccess import dev.dimension.flare.ui.presenter.profile.ProfileState import dev.dimension.flare.ui.route.DeeplinkRoute import dev.dimension.flare.ui.route.toUri @@ -80,7 +89,9 @@ public fun ProfileHeader( modifier = modifier, user = userState.data, relationState = state.relationState, + myAccountKey = state.myAccountKey, onFollowClick = state::follow, + onUnfollowClick = state::unfollow, isMe = state.isMe, menu = menu, expandMatrices = isBigScreen, @@ -97,7 +108,9 @@ public fun ProfileHeader( private fun ProfileHeaderSuccess( user: UiProfile, relationState: UiState, - onFollowClick: (userKey: MicroBlogKey, UiRelation) -> Unit, + myAccountKey: UiState, + onFollowClick: (userKey: MicroBlogKey) -> Unit, + onUnfollowClick: (userKey: MicroBlogKey) -> Unit, onAvatarClick: () -> Unit, onBannerClick: () -> Unit, isMe: UiState, @@ -136,32 +149,63 @@ private fun ProfileHeaderSuccess( } is UiState.Success -> { + val relation = relationState.data Column( horizontalAlignment = Alignment.CenterHorizontally, ) { - PlatformFilledTonalButton(onClick = { - onFollowClick.invoke(user.key, relationState.data) - }) { - PlatformText( - text = - stringResource( - when { - relationState.data.blocking -> - Res.string.profile_header_button_blocked + AnimatedContent( + targetState = FollowButtonState.from(relation), + transitionSpec = { + (fadeIn() + scaleIn(initialScale = 0.92f)) togetherWith + (fadeOut() + scaleOut(targetScale = 0.92f)) + }, + label = "profile_follow_button", + ) { buttonState -> + when (buttonState) { + FollowButtonState.Blocked -> + PlatformErrorButton( + onClick = { + uriLauncher.openUri( + DeeplinkRoute + .UnblockUser( + accountKey = myAccountKey.takeSuccess(), + userKey = user.key, + ).toUri(), + ) + }, + ) { + PlatformText(text = stringResource(Res.string.profile_header_button_blocked)) + } - relationState.data.following -> - Res.string.profile_header_button_following + FollowButtonState.Following -> + PlatformOutlinedButton( + onClick = { + onUnfollowClick.invoke(user.key) + }, + ) { + PlatformText(text = stringResource(Res.string.profile_header_button_following)) + } - relationState.data.hasPendingFollowRequestFromYou -> - Res.string.profile_header_button_requested + FollowButtonState.Requested -> + PlatformOutlinedButton( + onClick = { + onUnfollowClick.invoke(user.key) + }, + ) { + PlatformText(text = stringResource(Res.string.profile_header_button_requested)) + } - else -> - Res.string.profile_header_button_follow + FollowButtonState.Follow -> + PlatformFilledTonalButton( + onClick = { + onFollowClick.invoke(user.key) }, - ), - ) + ) { + PlatformText(text = stringResource(Res.string.profile_header_button_follow)) + } + } } - if (relationState.data.isFans) { + if (relation.isFans) { PlatformText( text = stringResource(Res.string.profile_header_button_is_fans), textAlign = TextAlign.Center, @@ -280,6 +324,24 @@ private fun ProfileHeaderSuccess( ) } +private enum class FollowButtonState { + Follow, + Requested, + Following, + Blocked, + ; + + companion object { + fun from(relation: UiRelation): FollowButtonState = + when { + relation.blocking -> Blocked + relation.following -> Following + relation.hasPendingFollowRequestFromYou -> Requested + else -> Follow + } + } +} + @Composable private fun ProfileHeaderError() { } diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DmListItem.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DmListItem.kt index 5e3fc5027..231ed21fc 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DmListItem.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DmListItem.kt @@ -131,7 +131,7 @@ public fun LazyListScope.dmList( ) if (item.users.size == 1) { PlatformText( - text = user.handle, + text = user.handle.canonical, style = PlatformTheme.typography.caption, color = PlatformTheme.colorScheme.caption, maxLines = 1, diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.kt index 80634c813..4f89faefb 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.kt @@ -28,6 +28,22 @@ internal expect fun PlatformFilledTonalButton( content: @Composable RowScope.() -> Unit, ) +@Composable +internal expect fun PlatformOutlinedButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit, +) + +@Composable +internal expect fun PlatformErrorButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit, +) + @Composable internal expect fun PlatformIconButton( onClick: () -> Unit, diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt index fb1b02c41..8332af9ea 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt @@ -59,6 +59,7 @@ import compose.icons.fontawesomeicons.regular.CommentDots import compose.icons.fontawesomeicons.regular.Heart import compose.icons.fontawesomeicons.solid.At import compose.icons.fontawesomeicons.solid.Bookmark +import compose.icons.fontawesomeicons.solid.Check import compose.icons.fontawesomeicons.solid.CircleInfo import compose.icons.fontawesomeicons.solid.Ellipsis import compose.icons.fontawesomeicons.solid.EllipsisVertical @@ -70,13 +71,16 @@ import compose.icons.fontawesomeicons.solid.Lock import compose.icons.fontawesomeicons.solid.LockOpen import compose.icons.fontawesomeicons.solid.Message import compose.icons.fontawesomeicons.solid.Minus +import compose.icons.fontawesomeicons.solid.Pen import compose.icons.fontawesomeicons.solid.Plus -import compose.icons.fontawesomeicons.solid.QuoteLeft import compose.icons.fontawesomeicons.solid.Reply import compose.icons.fontawesomeicons.solid.Retweet import compose.icons.fontawesomeicons.solid.ShareNodes +import compose.icons.fontawesomeicons.solid.SquarePollHorizontal +import compose.icons.fontawesomeicons.solid.Thumbtack import compose.icons.fontawesomeicons.solid.Trash import compose.icons.fontawesomeicons.solid.Tv +import compose.icons.fontawesomeicons.solid.UserPlus import compose.icons.fontawesomeicons.solid.UserSlash import compose.icons.fontawesomeicons.solid.VolumeXmark import dev.dimension.flare.compose.ui.Res @@ -122,7 +126,6 @@ import dev.dimension.flare.compose.ui.user_unmute import dev.dimension.flare.compose.ui.vote import dev.dimension.flare.data.datasource.microblog.ActionMenu import dev.dimension.flare.data.model.PostActionStyle -import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType import dev.dimension.flare.ui.component.AdaptiveGrid import dev.dimension.flare.ui.component.AvatarComponent @@ -147,14 +150,16 @@ import dev.dimension.flare.ui.component.platform.PlatformTextStyle import dev.dimension.flare.ui.icons.Misskey import dev.dimension.flare.ui.model.ClickContext import dev.dimension.flare.ui.model.UiCard +import dev.dimension.flare.ui.model.UiIcon import dev.dimension.flare.ui.model.UiMedia import dev.dimension.flare.ui.model.UiPoll -import dev.dimension.flare.ui.model.UiTimeline -import dev.dimension.flare.ui.model.collectAsUiState +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.model.onError import dev.dimension.flare.ui.model.onLoading import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.render.UiRichText +import dev.dimension.flare.ui.route.DeeplinkRoute +import dev.dimension.flare.ui.route.toUri import dev.dimension.flare.ui.theme.PlatformContentColor import dev.dimension.flare.ui.theme.PlatformTheme import kotlinx.collections.immutable.ImmutableList @@ -164,7 +169,7 @@ import org.jetbrains.compose.resources.stringResource @Composable public fun CommonStatusComponent( - item: UiTimeline.ItemContent.Status, + item: UiTimelineV2.Post, modifier: Modifier = Modifier, isDetail: Boolean = false, isQuote: Boolean = false, @@ -219,18 +224,14 @@ public fun CommonStatusComponent( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - when (val content = item.topEndContent) { - is UiTimeline.ItemContent.Status.TopEndContent.Visibility -> { - StatusVisibilityComponent( - visibility = content.visibility, - modifier = - Modifier - .size(PlatformTheme.typography.caption.fontSize.value.dp), - tint = PlatformTheme.colorScheme.caption, - ) - } - - null -> Unit + item.visibility?.let { + StatusVisibilityComponent( + visibility = it, + modifier = + Modifier + .size(PlatformTheme.typography.caption.fontSize.value.dp), + tint = PlatformTheme.colorScheme.caption, + ) } if (appearanceSettings.showPlatformLogo) { val icon = @@ -312,15 +313,11 @@ public fun CommonStatusComponent( } } } - when (val content = item.aboveTextContent) { - is UiTimeline.ItemContent.Status.AboveTextContent.ReplyTo -> { - Spacer(modifier = Modifier.height(4.dp)) - StatusReplyComponent( - replyHandle = content.handle, - ) - } - - null -> Unit + item.replyToHandle?.let { replyHandle -> + Spacer(modifier = Modifier.height(4.dp)) + StatusReplyComponent( + replyHandle = replyHandle, + ) } if (isDetail) { SelectionContainer { @@ -350,7 +347,7 @@ public fun CommonStatusComponent( if (isDetail && !item.content.isEmpty && appearanceSettings.showTranslateButton) { TranslationComponent( - statusKey = item.statusKey, + statusKey = item.itemKey, contentWarning = item.contentWarning, rawContent = item.content.innerText, content = item.content, @@ -362,15 +359,21 @@ public fun CommonStatusComponent( StatusMediasComponent( item, onMediaClick = { media -> - item.onMediaClicked.invoke( - ClickContext( - launcher = { - uriHandler.openUri(it) - }, - ), - media, - item.images.indexOf(media), - ) + val index = item.images.indexOf(media) + val link = + DeeplinkRoute.Media.StatusMedia( + statusKey = item.statusKey, + accountType = item.accountType, + index = index, + preview = + when (media) { + is UiMedia.Image -> media.previewUrl + is UiMedia.Video -> media.thumbnailUrl + is UiMedia.Gif -> media.previewUrl + is UiMedia.Audio -> null + }, + ) + uriHandler.openUri(link.toUri()) }, ) } @@ -395,19 +398,11 @@ public fun CommonStatusComponent( ) } - if (!isQuote) { - when (val content = item.bottomContent) { - is UiTimeline.ItemContent.Status.BottomContent.Reaction -> { - if (content.emojiReactions.isNotEmpty() || content.channel != null) { - Spacer(modifier = Modifier.height(4.dp)) - StatusReactionComponent( - data = content, - ) - } - } - - null -> Unit - } + if (!isQuote && (item.emojiReactions.isNotEmpty() || item.sourceChannel != null)) { + Spacer(modifier = Modifier.height(4.dp)) + StatusReactionComponent( + data = item, + ) } if (isDetail) { @@ -453,7 +448,7 @@ public fun CommonStatusComponent( @Composable internal fun StatusMediasComponent( - item: UiTimeline.ItemContent.Status, + item: UiTimelineV2.Post, onMediaClick: (UiMedia) -> Unit, ) { val appearanceSettings = LocalComponentAppearance.current @@ -520,7 +515,7 @@ internal fun StatusMediasComponent( @Composable private fun StatusQuoteComponent( - quotes: ImmutableList, + quotes: ImmutableList, modifier: Modifier = Modifier, ) { Box( @@ -560,14 +555,15 @@ private fun StatusQuoteComponent( @Composable private fun StatusReactionComponent( - data: UiTimeline.ItemContent.Status.BottomContent.Reaction, + data: UiTimelineV2.Post, modifier: Modifier = Modifier, ) { + val uriHandler = LocalUriHandler.current Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp), ) { - data.channel?.let { channel -> + data.sourceChannel?.let { channel -> Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -622,7 +618,9 @@ private fun StatusReactionComponent( modifier = Modifier .clickable { - reaction.onClicked.invoke() + reaction.onClicked.invoke( + ClickContext(uriHandler::openUri), + ) }.padding(horizontal = 8.dp, vertical = 4.dp), ) { if (reaction.isUnicode) { @@ -647,7 +645,7 @@ private fun StatusReactionComponent( @Composable private fun TranslationComponent( - statusKey: MicroBlogKey, + statusKey: String, contentWarning: UiRichText?, rawContent: String, content: UiRichText, @@ -758,12 +756,12 @@ private fun TranslationComponent( @Composable public fun StatusVisibilityComponent( - visibility: UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type, + visibility: UiTimelineV2.Post.Visibility, tint: Color = PlatformContentColor.current, modifier: Modifier = Modifier, ) { when (visibility) { - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Public -> + UiTimelineV2.Post.Visibility.Public -> FAIcon( imageVector = FontAwesomeIcons.Solid.Globe, contentDescription = stringResource(resource = Res.string.mastodon_visibility_public), @@ -771,7 +769,7 @@ public fun StatusVisibilityComponent( tint = tint, ) - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Home -> + UiTimelineV2.Post.Visibility.Home -> FAIcon( imageVector = FontAwesomeIcons.Solid.LockOpen, contentDescription = stringResource(resource = Res.string.mastodon_visibility_unlisted), @@ -779,7 +777,7 @@ public fun StatusVisibilityComponent( tint = tint, ) - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Followers -> + UiTimelineV2.Post.Visibility.Followers -> FAIcon( imageVector = FontAwesomeIcons.Solid.Lock, contentDescription = stringResource(resource = Res.string.mastodon_visibility_private), @@ -787,7 +785,7 @@ public fun StatusVisibilityComponent( tint = tint, ) - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Specified -> + UiTimelineV2.Post.Visibility.Specified -> FAIcon( imageVector = FontAwesomeIcons.Solid.At, contentDescription = stringResource(resource = Res.string.mastodon_visibility_direct), @@ -795,7 +793,7 @@ public fun StatusVisibilityComponent( tint = tint, ) - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Channel -> + UiTimelineV2.Post.Visibility.Channel -> FAIcon( imageVector = FontAwesomeIcons.Solid.Tv, contentDescription = stringResource(resource = Res.string.channel_title), @@ -843,44 +841,6 @@ internal fun StatusActions( StatusActionItemMenu(subActions, closeMenu, launcher) } - is ActionMenu.AsyncActionMenuItem -> { - if (isMenuShown) { - val state by subActions.flow.collectAsUiState() - state - .onSuccess { - StatusActionItemMenu(it, closeMenu, launcher) - }.onLoading { - PlatformDropdownMenuItem( - text = { - PlatformText( - text = "Loading", - modifier = - Modifier.placeholder( - true, - color = PlatformTheme.colorScheme.cardAlt, - ), - ) - }, - leadingIcon = { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Ellipsis, - contentDescription = "Loading", - modifier = - Modifier - .size(PlatformTextStyle.current.fontSize.value.dp + 2.dp) - .placeholder( - true, - color = PlatformTheme.colorScheme.cardAlt, - ), - ) - }, - onClick = { - }, - ) - } - } - } - // nested group is not supported is ActionMenu.Group -> Unit ActionMenu.Divider -> PlatformDropdownMenuDivider() @@ -896,7 +856,7 @@ internal fun StatusActions( color = action.color?.toComposeColor() ?: PlatformContentColor.current, withTextMinWidth = index != items.lastIndex, onClicked = { - action.onClicked?.let { onClick -> + action.onClicked.let { onClick -> haptics.performHapticFeedback(HapticFeedbackType.ContextClick) onClick.invoke( ClickContext( @@ -910,8 +870,6 @@ internal fun StatusActions( ) } - // async action item is only supported in group - is ActionMenu.AsyncActionMenuItem -> Unit // divider is only supported in group ActionMenu.Divider -> Unit } @@ -946,7 +904,7 @@ private fun PlatformDropdownMenuScope.StatusActionItemMenu( }, onClick = { closeMenu.invoke() - subActions.onClicked?.invoke( + subActions.onClicked.invoke( ClickContext( launcher = { launcher.openUri(it) @@ -988,6 +946,8 @@ private fun ActionMenu.Item.Text.asString(): String = ActionMenu.Item.Text.Localized.Type.UnBlock -> Res.string.user_unblock ActionMenu.Item.Text.Localized.Type.BlockWithHandleParameter -> Res.string.user_block_with_parameter ActionMenu.Item.Text.Localized.Type.MuteWithHandleParameter -> Res.string.user_mute_with_parameter + ActionMenu.Item.Text.Localized.Type.AcceptFollowRequest -> Res.string.more + ActionMenu.Item.Text.Localized.Type.RejectFollowRequest -> Res.string.more } stringResource(resource, *parameters.toTypedArray()) } @@ -1001,30 +961,38 @@ private fun ActionMenu.Item.Color.toComposeColor(): Color = ActionMenu.Item.Color.PrimaryColor -> PlatformTheme.colorScheme.retweetColor } -private fun ActionMenu.Item.Icon.toImageVector(): ImageVector = +internal fun UiIcon.toImageVector(): ImageVector = when (this) { - ActionMenu.Item.Icon.Like -> FontAwesomeIcons.Regular.Heart - ActionMenu.Item.Icon.Unlike -> FontAwesomeIcons.Solid.Heart - ActionMenu.Item.Icon.Retweet -> FontAwesomeIcons.Solid.Retweet - ActionMenu.Item.Icon.Unretweet -> FontAwesomeIcons.Solid.Retweet - ActionMenu.Item.Icon.Reply -> FontAwesomeIcons.Solid.Reply - ActionMenu.Item.Icon.Comment -> FontAwesomeIcons.Regular.CommentDots - ActionMenu.Item.Icon.Quote -> FontAwesomeIcons.Solid.QuoteLeft - ActionMenu.Item.Icon.Bookmark -> FontAwesomeIcons.Regular.Bookmark - ActionMenu.Item.Icon.Unbookmark -> FontAwesomeIcons.Solid.Bookmark - ActionMenu.Item.Icon.More -> FontAwesomeIcons.Solid.Ellipsis - ActionMenu.Item.Icon.Delete -> FontAwesomeIcons.Solid.Trash - ActionMenu.Item.Icon.Report -> FontAwesomeIcons.Solid.CircleInfo - ActionMenu.Item.Icon.React -> FontAwesomeIcons.Solid.Plus - ActionMenu.Item.Icon.UnReact -> FontAwesomeIcons.Solid.Minus - ActionMenu.Item.Icon.Share -> FontAwesomeIcons.Solid.ShareNodes - ActionMenu.Item.Icon.MoreVerticel -> FontAwesomeIcons.Solid.EllipsisVertical - ActionMenu.Item.Icon.List -> FontAwesomeIcons.Solid.List - ActionMenu.Item.Icon.ChatMessage -> FontAwesomeIcons.Solid.Message - ActionMenu.Item.Icon.Mute -> FontAwesomeIcons.Solid.VolumeXmark - ActionMenu.Item.Icon.UnMute -> FontAwesomeIcons.Solid.VolumeXmark - ActionMenu.Item.Icon.Block -> FontAwesomeIcons.Solid.UserSlash - ActionMenu.Item.Icon.UnBlock -> FontAwesomeIcons.Solid.UserSlash + UiIcon.Like -> FontAwesomeIcons.Regular.Heart + UiIcon.Unlike -> FontAwesomeIcons.Solid.Heart + UiIcon.Retweet -> FontAwesomeIcons.Solid.Retweet + UiIcon.Unretweet -> FontAwesomeIcons.Solid.Retweet + UiIcon.Reply -> FontAwesomeIcons.Solid.Reply + UiIcon.Comment -> FontAwesomeIcons.Regular.CommentDots + UiIcon.Quote -> FontAwesomeIcons.Solid.Reply + UiIcon.Bookmark -> FontAwesomeIcons.Regular.Bookmark + UiIcon.Unbookmark -> FontAwesomeIcons.Solid.Bookmark + UiIcon.More -> FontAwesomeIcons.Solid.Ellipsis + UiIcon.Delete -> FontAwesomeIcons.Solid.Trash + UiIcon.Report -> FontAwesomeIcons.Solid.CircleInfo + UiIcon.React -> FontAwesomeIcons.Solid.Plus + UiIcon.UnReact -> FontAwesomeIcons.Solid.Minus + UiIcon.Share -> FontAwesomeIcons.Solid.ShareNodes + UiIcon.MoreVerticel -> FontAwesomeIcons.Solid.EllipsisVertical + UiIcon.List -> FontAwesomeIcons.Solid.List + UiIcon.ChatMessage -> FontAwesomeIcons.Solid.Message + UiIcon.Mute -> FontAwesomeIcons.Solid.VolumeXmark + UiIcon.UnMute -> FontAwesomeIcons.Solid.VolumeXmark + UiIcon.Block -> FontAwesomeIcons.Solid.UserSlash + UiIcon.UnBlock -> FontAwesomeIcons.Solid.UserSlash + UiIcon.Follow -> FontAwesomeIcons.Solid.UserPlus + UiIcon.Favourite -> FontAwesomeIcons.Solid.Heart + UiIcon.Mention -> FontAwesomeIcons.Solid.At + UiIcon.Poll -> FontAwesomeIcons.Solid.SquarePollHorizontal + UiIcon.Edit -> FontAwesomeIcons.Solid.Pen + UiIcon.Info -> FontAwesomeIcons.Solid.CircleInfo + UiIcon.Pin -> FontAwesomeIcons.Solid.Thumbtack + UiIcon.Check -> FontAwesomeIcons.Solid.Check } @Composable @@ -1204,10 +1172,14 @@ private fun StatusPollComponent( } } if (poll.canVote) { + val uriHandler = LocalUriHandler.current PlatformFilledTonalButton( modifier = Modifier.fillMaxWidth(), onClick = { - poll.onVote.invoke(selectedOptions.toImmutableList()) + poll.onVote.invoke( + ClickContext(launcher = uriHandler::openUri), + selectedOptions.toImmutableList(), + ) }, ) { PlatformText( diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusHeaderComponent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusHeaderComponent.kt index 4d2c7173c..e6a13c932 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusHeaderComponent.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusHeaderComponent.kt @@ -61,7 +61,7 @@ public fun CommonStatusHeaderComponent( }, supportingContent = { PlatformText( - text = data.handle, + text = data.handle.canonical, style = PlatformTheme.typography.caption, color = PlatformTheme.colorScheme.caption, modifier = diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/FeedComponent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/FeedComponent.kt index e7bd26342..306cde4a8 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/FeedComponent.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/FeedComponent.kt @@ -16,13 +16,13 @@ import dev.dimension.flare.ui.component.DateTimeText import dev.dimension.flare.ui.component.NetworkImage import dev.dimension.flare.ui.component.platform.PlatformText import dev.dimension.flare.ui.model.ClickContext -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.theme.PlatformTheme import dev.dimension.flare.ui.theme.screenHorizontalPadding @Composable internal fun FeedComponent( - data: UiTimeline.ItemContent.Feed, + data: UiTimelineV2.Feed, modifier: Modifier = Modifier, ) { val uriHandler = LocalUriHandler.current @@ -43,20 +43,20 @@ internal fun FeedComponent( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, ) { - data.sourceIcon?.let { + data.source.icon?.let { NetworkImage( it, - contentDescription = data.source, + contentDescription = data.source.name, modifier = Modifier.size(16.dp), ) } PlatformText( - text = data.source, + text = data.source.name, style = PlatformTheme.typography.caption, modifier = Modifier.weight(1f), maxLines = 1, ) - data.createdAt?.let { + data.actualCreatedAt?.let { DateTimeText( it, style = PlatformTheme.typography.caption, @@ -81,7 +81,7 @@ internal fun FeedComponent( color = PlatformTheme.colorScheme.caption, modifier = Modifier.let { - if (data.image != null) { + if (data.media != null) { it.weight(1f) } else { it @@ -89,9 +89,9 @@ internal fun FeedComponent( }, ) } - data.image?.let { + data.media?.let { NetworkImage( - model = it, + model = it.url, contentDescription = data.title, modifier = Modifier @@ -104,7 +104,7 @@ internal fun FeedComponent( }.clip( PlatformTheme.shapes.medium, ), - customHeaders = data.imageHeaders, + customHeaders = it.customHeaders, ) } } diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusItems.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusItems.kt index 5bb744168..a701143c8 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusItems.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusItems.kt @@ -18,7 +18,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid @@ -37,13 +36,13 @@ import dev.dimension.flare.ui.component.ErrorContent import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.placeholder import dev.dimension.flare.ui.component.platform.PlatformText -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.theme.PlatformTheme import dev.dimension.flare.ui.theme.screenHorizontalPadding import org.jetbrains.compose.resources.stringResource public fun LazyStaggeredGridScope.status( - pagingState: PagingState, + pagingState: PagingState, detailStatusKey: MicroBlogKey? = null, ): Unit = with(pagingState) { @@ -222,17 +221,15 @@ private fun OnError( @Composable public fun StatusItem( - item: UiTimeline?, -// event: StatusEvent, + item: UiTimelineV2?, modifier: Modifier = Modifier, detailStatusKey: MicroBlogKey? = null, - horizontalPadding: Dp = screenHorizontalPadding, ) { if (item == null) { Column( modifier = modifier.padding( - horizontal = horizontalPadding, + horizontal = screenHorizontalPadding, vertical = 8.dp, ), ) { @@ -243,7 +240,6 @@ public fun StatusItem( item = item, detailStatusKey = detailStatusKey, modifier = modifier, - horizontalPadding = screenHorizontalPadding, ) } } diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/UiTimelineComponent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/UiTimelineComponent.kt index 59b0e86f8..a205b0cf4 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/UiTimelineComponent.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/UiTimelineComponent.kt @@ -4,10 +4,8 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement 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.calculateStartPadding import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding @@ -18,37 +16,17 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.Layout -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach import androidx.compose.ui.zIndex import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid -import compose.icons.fontawesomeicons.solid.At import compose.icons.fontawesomeicons.solid.Check -import compose.icons.fontawesomeicons.solid.CircleInfo -import compose.icons.fontawesomeicons.solid.Heart -import compose.icons.fontawesomeicons.solid.Pen -import compose.icons.fontawesomeicons.solid.QuoteLeft -import compose.icons.fontawesomeicons.solid.Reply -import compose.icons.fontawesomeicons.solid.Retweet -import compose.icons.fontawesomeicons.solid.SquarePollHorizontal -import compose.icons.fontawesomeicons.solid.Thumbtack -import compose.icons.fontawesomeicons.solid.UserPlus import compose.icons.fontawesomeicons.solid.Xmark import dev.dimension.flare.compose.ui.Res -import dev.dimension.flare.compose.ui.bluesky_notification_item_favourited_your_status -import dev.dimension.flare.compose.ui.bluesky_notification_item_followed_you -import dev.dimension.flare.compose.ui.bluesky_notification_item_mentioned_you -import dev.dimension.flare.compose.ui.bluesky_notification_item_pin -import dev.dimension.flare.compose.ui.bluesky_notification_item_quoted_your_status -import dev.dimension.flare.compose.ui.bluesky_notification_item_reblogged_your_status -import dev.dimension.flare.compose.ui.bluesky_notification_item_replied_to_you import dev.dimension.flare.compose.ui.bluesky_notification_item_starterpack_joined -import dev.dimension.flare.compose.ui.bluesky_notification_item_unKnown import dev.dimension.flare.compose.ui.mastodon_item_pinned import dev.dimension.flare.compose.ui.mastodon_notification_item_favourited_your_status import dev.dimension.flare.compose.ui.mastodon_notification_item_followed_you @@ -217,20 +195,10 @@ import dev.dimension.flare.compose.ui.misskey_achievement_view_instance_chart_ti import dev.dimension.flare.compose.ui.misskey_notification_item_achievement_earned import dev.dimension.flare.compose.ui.misskey_notification_item_app import dev.dimension.flare.compose.ui.misskey_notification_item_follow_request_accepted -import dev.dimension.flare.compose.ui.misskey_notification_item_followed_you -import dev.dimension.flare.compose.ui.misskey_notification_item_mentioned_you -import dev.dimension.flare.compose.ui.misskey_notification_item_poll_ended -import dev.dimension.flare.compose.ui.misskey_notification_item_quoted_your_status import dev.dimension.flare.compose.ui.misskey_notification_item_reacted_to_your_status -import dev.dimension.flare.compose.ui.misskey_notification_item_replied_to_you -import dev.dimension.flare.compose.ui.misskey_notification_item_reposted_your_status -import dev.dimension.flare.compose.ui.misskey_notification_item_requested_follow -import dev.dimension.flare.compose.ui.misskey_notification_unknwon import dev.dimension.flare.compose.ui.notification_item_accept_follow_request import dev.dimension.flare.compose.ui.notification_item_reject_follow_request -import dev.dimension.flare.compose.ui.vvo_notification_like -import dev.dimension.flare.compose.ui.xqt_item_mention_status -import dev.dimension.flare.compose.ui.xqt_item_reblogged_status +import dev.dimension.flare.data.datasource.microblog.ActionMenu import dev.dimension.flare.data.model.PostActionStyle import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.component.AvatarComponentDefaults @@ -243,9 +211,8 @@ import dev.dimension.flare.ui.component.platform.PlatformCard import dev.dimension.flare.ui.component.platform.PlatformFilledTonalButton import dev.dimension.flare.ui.component.platform.PlatformText import dev.dimension.flare.ui.component.platform.PlatformTextStyle -import dev.dimension.flare.ui.component.platform.isBigScreen import dev.dimension.flare.ui.model.ClickContext -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.model.mapper.MisskeyAchievement import dev.dimension.flare.ui.theme.PlatformContentColor import dev.dimension.flare.ui.theme.PlatformTheme @@ -255,192 +222,165 @@ import org.jetbrains.compose.resources.stringResource @Composable internal fun UiTimelineComponent( - item: UiTimeline, + item: UiTimelineV2, modifier: Modifier = Modifier, detailStatusKey: MicroBlogKey? = null, - horizontalPadding: Dp = screenHorizontalPadding, ) { - val bigScreen = isBigScreen() - val appearance = LocalComponentAppearance.current - Column( - modifier = modifier, - ) { - item.topMessage?.let { - TopMessageComponent( - data = it, - topMessageOnly = item.content == null, + when (item) { + is UiTimelineV2.Post -> + StatusContent( + data = item, + detailStatusKey = detailStatusKey, + modifier = modifier, + ) + + is UiTimelineV2.User -> { + UserContent( + item, modifier = Modifier - .padding(horizontal = horizontalPadding) - .let { - if (item.content == null) { - it.padding(vertical = 8.dp) - } else { - if (!appearance.fullWidthPost) { - it.padding( - top = 8.dp, - start = AvatarComponentDefaults.size - PlatformTheme.typography.caption.fontSize.value.dp, - ) - } else { - it.padding(top = 8.dp) - } - } - }.fillMaxWidth(), + .padding(horizontal = screenHorizontalPadding) + .padding(vertical = 8.dp), ) } - item.content?.let { - val padding = - if (item.topMessage == null) { - PaddingValues( - start = horizontalPadding, - end = horizontalPadding, - bottom = 8.dp, - top = if (bigScreen) 16.dp else 8.dp, - ) - } else { - PaddingValues( - start = horizontalPadding, - end = horizontalPadding, - bottom = 8.dp, - top = 8.dp, - ) - } - ItemContentComponent( - item = it, - detailStatusKey = detailStatusKey, - paddingValues = padding, + + is UiTimelineV2.UserList -> + UserListContent( + data = item, + modifier = + Modifier + .padding(horizontal = screenHorizontalPadding) + .padding(vertical = 8.dp), + ) + + is UiTimelineV2.Feed -> { + FeedComponent( + data = item, + modifier = modifier, ) } + + is UiTimelineV2.Message -> + TopMessageComponent( + data = item, + topMessageOnly = true, + modifier = + Modifier + .padding(horizontal = screenHorizontalPadding) + .padding(vertical = 8.dp) + .fillMaxWidth(), + ) } } @Composable -private fun ItemContentComponent( - item: UiTimeline.ItemContent, - detailStatusKey: MicroBlogKey?, - paddingValues: PaddingValues, +private fun UserContent( + item: UiTimelineV2.User, modifier: Modifier = Modifier, ) { val uriHandler = LocalUriHandler.current - when (item) { - is UiTimeline.ItemContent.Status -> - StatusContent( - data = item, - detailStatusKey = detailStatusKey, - paddingValues = paddingValues, - modifier = modifier, - ) - - is UiTimeline.ItemContent.User -> { - Column( + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item.message?.let { message -> + TopMessageComponent( + data = message, + topMessageOnly = false, modifier = - modifier - .padding(paddingValues), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - CommonStatusHeaderComponent( - data = item.value, - onUserClick = { - item.value.onClicked.invoke( - ClickContext( - launcher = { - uriHandler.openUri(it) - }, - ), - ) - }, + Modifier + .fillMaxWidth(), + ) + } + CommonStatusHeaderComponent( + data = item.value, + onUserClick = { + item.value.onClicked.invoke( + ClickContext( + launcher = { + uriHandler.openUri(it) + }, + ), ) - if (item.button.isNotEmpty()) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - item.button.fastForEach { button -> - when (button) { - is UiTimeline.ItemContent.User.Button.AcceptFollowRequest -> - PlatformFilledTonalButton( - onClick = { - button.onClicked.invoke( - ClickContext( - launcher = { - uriHandler.openUri(it) - }, - ), - ) - }, - ) { - FAIcon( - FontAwesomeIcons.Solid.Check, - contentDescription = - stringResource( - Res.string.notification_item_accept_follow_request, - ), - ) - Spacer(modifier = Modifier.width(8.dp)) - PlatformText( - text = - stringResource( - Res.string.notification_item_accept_follow_request, - ), - ) - } - is UiTimeline.ItemContent.User.Button.RejectFollowRequest -> { - PlatformButton( - onClick = { - button.onClicked.invoke( - ClickContext( - launcher = { - uriHandler.openUri(it) - }, - ), - ) - }, - content = { - FAIcon( - FontAwesomeIcons.Solid.Xmark, - contentDescription = - stringResource( - Res.string.notification_item_reject_follow_request, - ), - tint = PlatformTheme.colorScheme.error, - ) - Spacer(modifier = Modifier.width(8.dp)) - PlatformText( - text = - stringResource( - Res.string.notification_item_reject_follow_request, - ), - color = PlatformTheme.colorScheme.error, - ) - }, + }, + ) + if (item.button.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + item.button.fastForEach { button -> + when ((button.text as? ActionMenu.Item.Text.Localized)?.type) { + ActionMenu.Item.Text.Localized.Type.AcceptFollowRequest -> + PlatformFilledTonalButton( + onClick = { + button.onClicked.invoke( + ClickContext( + launcher = { + uriHandler.openUri(it) + }, + ), ) - } + }, + ) { + FAIcon( + FontAwesomeIcons.Solid.Check, + contentDescription = + stringResource( + Res.string.notification_item_accept_follow_request, + ), + ) + Spacer(modifier = Modifier.width(8.dp)) + PlatformText( + text = + stringResource( + Res.string.notification_item_accept_follow_request, + ), + ) } + + ActionMenu.Item.Text.Localized.Type.RejectFollowRequest -> { + PlatformButton( + onClick = { + button.onClicked.invoke( + ClickContext( + launcher = { + uriHandler.openUri(it) + }, + ), + ) + }, + content = { + FAIcon( + FontAwesomeIcons.Solid.Xmark, + contentDescription = + stringResource( + Res.string.notification_item_reject_follow_request, + ), + tint = PlatformTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.width(8.dp)) + PlatformText( + text = + stringResource( + Res.string.notification_item_reject_follow_request, + ), + color = PlatformTheme.colorScheme.error, + ) + }, + ) } + + else -> Unit } } } } - - is UiTimeline.ItemContent.UserList -> - UserListContent( - data = item, - modifier = - modifier - .padding(paddingValues), - ) - - is UiTimeline.ItemContent.Feed -> { - FeedComponent( - data = item, - modifier = modifier, - ) - } } } @Composable private fun UserListContent( - data: UiTimeline.ItemContent.UserList, + data: UiTimelineV2.UserList, modifier: Modifier = Modifier, ) { val uriHandler = LocalUriHandler.current @@ -448,6 +388,16 @@ private fun UserListContent( modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp), ) { + data.message?.let { message -> + TopMessageComponent( + data = message, + topMessageOnly = false, + modifier = + Modifier + .fillMaxWidth() + .padding(), + ) + } LazyRow( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -472,7 +422,7 @@ private fun UserListContent( } } } - val status = data.status + val status = data.post if (status != null) { CompositionLocalProvider( LocalComponentAppearance provides @@ -500,10 +450,9 @@ private fun UserListContent( @Composable private fun StatusContent( - data: UiTimeline.ItemContent.Status, + data: UiTimelineV2.Post, detailStatusKey: MicroBlogKey?, modifier: Modifier = Modifier, - paddingValues: PaddingValues = PaddingValues(0.dp), ) { Column( modifier = modifier, @@ -513,7 +462,10 @@ private fun StatusContent( Layout( content = { CompositionLocalProvider( - LocalComponentAppearance provides LocalComponentAppearance.current.copy(fullWidthPost = false), + LocalComponentAppearance provides + LocalComponentAppearance.current.copy( + fullWidthPost = false, + ), ) { Column( verticalArrangement = Arrangement.spacedBy(2.dp), @@ -522,7 +474,11 @@ private fun StatusContent( CommonStatusComponent( item = it, isDetail = false, - modifier = Modifier.padding(paddingValues), + modifier = + Modifier.padding( + horizontal = screenHorizontalPadding, + vertical = 8.dp, + ), ) } } @@ -532,7 +488,7 @@ private fun StatusContent( Modifier .zIndex(-1f) .padding( - start = paddingValues.calculateStartPadding(LocalLayoutDirection.current), + start = screenHorizontalPadding, ).padding(start = AvatarComponentDefaults.size / 2) .offset(y = AvatarComponentDefaults.size / 2), ) @@ -563,210 +519,110 @@ private fun StatusContent( }, ) } - CommonStatusComponent( - item = data, - isDetail = detailStatusKey == data.statusKey, - modifier = Modifier.padding(paddingValues), - ) + Column { + data.message?.let { message -> + TopMessageComponent( + data = message, + topMessageOnly = false, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .padding( + horizontal = screenHorizontalPadding, + ), + ) + } + CommonStatusComponent( + item = data, + isDetail = detailStatusKey?.toString()?.let(data.itemKey::contains) == true, + modifier = + Modifier + .padding( + horizontal = screenHorizontalPadding, + vertical = 8.dp, + ), + ) + } } } @Composable private fun TopMessageComponent( - data: UiTimeline.TopMessage, + data: UiTimelineV2.Message, topMessageOnly: Boolean, modifier: Modifier = Modifier, ) { + val appearance = LocalComponentAppearance.current val uriHandler = LocalUriHandler.current - val icon = - when (data.icon) { - UiTimeline.TopMessage.Icon.Retweet -> FontAwesomeIcons.Solid.Retweet - UiTimeline.TopMessage.Icon.Follow -> FontAwesomeIcons.Solid.UserPlus - UiTimeline.TopMessage.Icon.Favourite -> FontAwesomeIcons.Solid.Heart - UiTimeline.TopMessage.Icon.Mention -> FontAwesomeIcons.Solid.At - UiTimeline.TopMessage.Icon.Poll -> FontAwesomeIcons.Solid.SquarePollHorizontal - UiTimeline.TopMessage.Icon.Edit -> FontAwesomeIcons.Solid.Pen - UiTimeline.TopMessage.Icon.Info -> FontAwesomeIcons.Solid.CircleInfo - UiTimeline.TopMessage.Icon.Reply -> FontAwesomeIcons.Solid.Reply - UiTimeline.TopMessage.Icon.Quote -> FontAwesomeIcons.Solid.QuoteLeft - UiTimeline.TopMessage.Icon.Pin -> FontAwesomeIcons.Solid.Thumbtack - } + val icon = data.icon.toImageVector() val text: String? = when (val type = data.type) { - is UiTimeline.TopMessage.MessageType.Bluesky -> - when (type) { - UiTimeline.TopMessage.MessageType.Bluesky.Follow -> - stringResource(resource = Res.string.bluesky_notification_item_followed_you) - - UiTimeline.TopMessage.MessageType.Bluesky.Like -> - stringResource( - resource = Res.string.bluesky_notification_item_favourited_your_status, - ) - - UiTimeline.TopMessage.MessageType.Bluesky.Mention -> - stringResource( - resource = Res.string.bluesky_notification_item_mentioned_you, - ) - - UiTimeline.TopMessage.MessageType.Bluesky.Quote -> - stringResource( - resource = Res.string.bluesky_notification_item_quoted_your_status, - ) - - UiTimeline.TopMessage.MessageType.Bluesky.Reply -> - stringResource( - resource = Res.string.bluesky_notification_item_replied_to_you, - ) - - UiTimeline.TopMessage.MessageType.Bluesky.Repost -> - stringResource( - resource = Res.string.bluesky_notification_item_reblogged_your_status, - ) - - UiTimeline.TopMessage.MessageType.Bluesky.StarterpackJoined -> - stringResource( - resource = Res.string.bluesky_notification_item_starterpack_joined, - ) - UiTimeline.TopMessage.MessageType.Bluesky.UnKnown -> - stringResource( - resource = Res.string.bluesky_notification_item_unKnown, - ) + is UiTimelineV2.Message.Type.Raw -> type.content + is UiTimelineV2.Message.Type.Unknown -> type.rawType.ifBlank { null } + is UiTimelineV2.Message.Type.Localized -> + when (type.data) { + UiTimelineV2.Message.Type.Localized.MessageId.Mention -> + stringResource(resource = Res.string.mastodon_notification_item_mentioned_you) - UiTimeline.TopMessage.MessageType.Bluesky.Pinned -> - stringResource( - resource = Res.string.bluesky_notification_item_pin, - ) - } + UiTimelineV2.Message.Type.Localized.MessageId.NewPost -> + stringResource(resource = Res.string.mastodon_notification_item_posted_status) - is UiTimeline.TopMessage.MessageType.Mastodon -> - when (type) { - is UiTimeline.TopMessage.MessageType.Mastodon.Favourite -> - stringResource( - resource = Res.string.mastodon_notification_item_favourited_your_status, - ) + UiTimelineV2.Message.Type.Localized.MessageId.Repost -> + stringResource(resource = Res.string.mastodon_notification_item_reblogged_your_status) - is UiTimeline.TopMessage.MessageType.Mastodon.Follow -> - stringResource( - resource = Res.string.mastodon_notification_item_followed_you, - ) + UiTimelineV2.Message.Type.Localized.MessageId.Follow -> + stringResource(resource = Res.string.mastodon_notification_item_followed_you) - is UiTimeline.TopMessage.MessageType.Mastodon.FollowRequest -> - stringResource( - resource = Res.string.mastodon_notification_item_requested_follow, - ) + UiTimelineV2.Message.Type.Localized.MessageId.FollowRequest -> + stringResource(resource = Res.string.mastodon_notification_item_requested_follow) - is UiTimeline.TopMessage.MessageType.Mastodon.Mention -> - stringResource( - resource = Res.string.mastodon_notification_item_mentioned_you, - ) + UiTimelineV2.Message.Type.Localized.MessageId.Favourite -> + stringResource(resource = Res.string.mastodon_notification_item_favourited_your_status) - is UiTimeline.TopMessage.MessageType.Mastodon.Poll -> + UiTimelineV2.Message.Type.Localized.MessageId.PollEnded -> stringResource(resource = Res.string.mastodon_notification_item_poll_ended) - is UiTimeline.TopMessage.MessageType.Mastodon.Reblogged -> - stringResource( - resource = Res.string.mastodon_notification_item_reblogged_your_status, - ) - - is UiTimeline.TopMessage.MessageType.Mastodon.Status -> - stringResource( - resource = Res.string.mastodon_notification_item_posted_status, - ) - - is UiTimeline.TopMessage.MessageType.Mastodon.Update -> - stringResource( - resource = Res.string.mastodon_notification_item_updated_status, - ) - - is UiTimeline.TopMessage.MessageType.Mastodon.UnKnown -> null - is UiTimeline.TopMessage.MessageType.Mastodon.Pinned -> - stringResource( - resource = Res.string.mastodon_item_pinned, - ) - } - - is UiTimeline.TopMessage.MessageType.Misskey -> - when (type) { - is UiTimeline.TopMessage.MessageType.Misskey.AchievementEarned -> - stringResource( - resource = Res.string.misskey_notification_item_achievement_earned, - type.achievement?.titleResId?.let { stringResource(it) } ?: "", - type.achievement?.descriptionResId?.let { stringResource(it) } ?: "", - ) - - is UiTimeline.TopMessage.MessageType.Misskey.App -> - stringResource(resource = Res.string.misskey_notification_item_app) - - is UiTimeline.TopMessage.MessageType.Misskey.Follow -> - stringResource(resource = Res.string.misskey_notification_item_followed_you) - - is UiTimeline.TopMessage.MessageType.Misskey.FollowRequestAccepted -> - stringResource( - resource = Res.string.misskey_notification_item_follow_request_accepted, - ) - - is UiTimeline.TopMessage.MessageType.Misskey.Mention -> - stringResource( - resource = Res.string.misskey_notification_item_mentioned_you, - ) - - is UiTimeline.TopMessage.MessageType.Misskey.PollEnded -> - stringResource( - resource = Res.string.misskey_notification_item_poll_ended, - ) - - is UiTimeline.TopMessage.MessageType.Misskey.Quote -> - stringResource( - resource = Res.string.misskey_notification_item_quoted_your_status, - ) - - is UiTimeline.TopMessage.MessageType.Misskey.Reaction -> - stringResource( - resource = Res.string.misskey_notification_item_reacted_to_your_status, - ) + UiTimelineV2.Message.Type.Localized.MessageId.PostUpdated -> + stringResource(resource = Res.string.mastodon_notification_item_updated_status) - is UiTimeline.TopMessage.MessageType.Misskey.ReceiveFollowRequest -> - stringResource( - resource = Res.string.misskey_notification_item_requested_follow, - ) + UiTimelineV2.Message.Type.Localized.MessageId.Reply -> + stringResource(resource = Res.string.mastodon_notification_item_mentioned_you) - is UiTimeline.TopMessage.MessageType.Misskey.Renote -> - stringResource( - resource = Res.string.misskey_notification_item_reposted_your_status, - ) + UiTimelineV2.Message.Type.Localized.MessageId.Quote -> + stringResource(resource = Res.string.mastodon_notification_item_reblogged_your_status) - is UiTimeline.TopMessage.MessageType.Misskey.Reply -> - stringResource( - resource = Res.string.misskey_notification_item_replied_to_you, - ) + UiTimelineV2.Message.Type.Localized.MessageId.Reaction -> + stringResource(resource = Res.string.misskey_notification_item_reacted_to_your_status) - is UiTimeline.TopMessage.MessageType.Misskey.UnKnown -> - stringResource( - resource = Res.string.misskey_notification_unknwon, - type.type, - ) + UiTimelineV2.Message.Type.Localized.MessageId.FollowRequestAccepted -> + stringResource(resource = Res.string.misskey_notification_item_follow_request_accepted) - is UiTimeline.TopMessage.MessageType.Misskey.Pinned -> - stringResource( - resource = Res.string.mastodon_item_pinned, - ) - } + UiTimelineV2.Message.Type.Localized.MessageId.AchievementEarned -> { + runCatching { + MisskeyAchievement.valueOf(type.args.getOrNull(0).orEmpty()) + }.getOrNull()?.let { achievement -> + stringResource( + resource = Res.string.misskey_notification_item_achievement_earned, + stringResource(achievement.titleResId), + stringResource(achievement.descriptionResId), + ) + } + ?: stringResource( + resource = Res.string.misskey_notification_item_achievement_earned, + type.args.getOrNull(0).orEmpty(), + "", + ) + } - is UiTimeline.TopMessage.MessageType.VVO -> - when (type) { - is UiTimeline.TopMessage.MessageType.VVO.Custom -> type.message - UiTimeline.TopMessage.MessageType.VVO.Like -> - stringResource(resource = Res.string.vvo_notification_like) - } + UiTimelineV2.Message.Type.Localized.MessageId.App -> + stringResource(resource = Res.string.misskey_notification_item_app) - is UiTimeline.TopMessage.MessageType.XQT -> - when (type) { - is UiTimeline.TopMessage.MessageType.XQT.Custom -> type.message - UiTimeline.TopMessage.MessageType.XQT.Mention -> - stringResource(resource = Res.string.xqt_item_mention_status) + UiTimelineV2.Message.Type.Localized.MessageId.StarterpackJoined -> + stringResource(resource = Res.string.bluesky_notification_item_starterpack_joined) - UiTimeline.TopMessage.MessageType.XQT.Retweet -> - stringResource(resource = Res.string.xqt_item_reblogged_status) + UiTimelineV2.Message.Type.Localized.MessageId.Pinned -> + stringResource(resource = Res.string.mastodon_item_pinned) } } @@ -797,7 +653,16 @@ private fun TopMessageComponent( }, ), ) - }.then(modifier), + }.let { + if (!appearance.fullWidthPost && !topMessageOnly) { + it.padding( + start = AvatarComponentDefaults.size - PlatformTheme.typography.caption.fontSize.value.dp, + ) + } else { + it + } + }.fillMaxWidth() + .then(modifier), ) } } @@ -950,6 +815,7 @@ private val MisskeyAchievement.descriptionResId: StringResource MisskeyAchievement.VIEW_INSTANCE_CHART -> Res.string.misskey_achievement_view_instance_chart_description MisskeyAchievement.OUTPUT_HELLO_WORLD_ON_SCRATCHPAD -> Res.string.misskey_achievement_output_hello_world_on_scratchpad_description + MisskeyAchievement.OPEN3WINDOWS -> Res.string.misskey_achievement_open3windows_description MisskeyAchievement.DRIVE_FOLDER_CIRCULAR_REFERENCE -> Res.string.misskey_achievement_drive_folder_circular_reference_description MisskeyAchievement.REACT_WITHOUT_READ -> Res.string.misskey_achievement_react_without_read_description diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/UserCompat.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/UserCompat.kt index 9decec3db..66dc008b7 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/UserCompat.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/UserCompat.kt @@ -57,7 +57,7 @@ internal fun UserCompat( modifier = Modifier.alignByBaseline(), ) PlatformText( - text = handle, + text = handle.canonical, style = PlatformTheme.typography.caption, color = PlatformTheme.colorScheme.caption, maxLines = 1, diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTabsPresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTabsPresenter.kt index dd166b9a0..d6f24697f 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTabsPresenter.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTabsPresenter.kt @@ -62,7 +62,7 @@ public class HomeTabsPresenter : tabsState.secondaryItems ?: TimelineTabItem.defaultSecondary(account) State.HomeTabState( primary = - TimelineTabItem.default.toImmutableList(), + TimelineTabItem.default(account.accountKey).toImmutableList(), secondary = secondary.toImmutableList(), extraProfileRoute = diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt index d96387d01..2c319a5f7 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt @@ -108,12 +108,28 @@ public class HomeTimelineWithTabsPresenter( settingsRepository.updateTabSettings { copy( mainTabs = - mainTabs.filterNot { - it.account == - AccountType.Specific( - accountKey, - ) - }, + mainTabs + .filterNot { + it.account == + AccountType.Specific( + accountKey, + ) + }.map { + if (it is MixedTimelineTabItem) { + it.copy( + subTimelineTabItem = + it.subTimelineTabItem + .filterNot { + it.account == + AccountType.Specific( + accountKey, + ) + }.toImmutableList(), + ) + } else { + it + } + }, ) } } diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/TimelineItemPresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/TimelineItemPresenter.kt index 6193e9e58..87afbaff4 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/TimelineItemPresenter.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/TimelineItemPresenter.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.rememberCoroutineScope import dev.dimension.flare.common.PagingState import dev.dimension.flare.common.isRefreshing import dev.dimension.flare.data.model.TimelineTabItem -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.presenter.home.NotificationBadgePresenter import kotlinx.coroutines.launch @@ -13,7 +13,7 @@ public class TimelineItemPresenter( private val timelineTabItem: TimelineTabItem, ) : PresenterBase() { public interface State { - public val listState: PagingState + public val listState: PagingState public fun refreshSync() diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/settings/AllTabsPresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/settings/AllTabsPresenter.kt index 7fcf4894a..7ebc958e7 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/settings/AllTabsPresenter.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/settings/AllTabsPresenter.kt @@ -78,8 +78,15 @@ public class AllTabsPresenter( } return object : State { + val defaultAccountKey = + accountTabs + .takeSuccess() + ?.firstOrNull() + ?.profile + ?.key override val defaultTabs = - TimelineTabItem.mainSidePanel + TimelineTabItem + .mainSidePanel(defaultAccountKey) .let { if (filterIsTimeline) { it.filterIsInstance() diff --git a/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.ios.kt b/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.ios.kt index 6f67d072e..120b3a75f 100644 --- a/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.ios.kt +++ b/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.ios.kt @@ -5,10 +5,12 @@ package dev.dimension.flare.ui.component.platform import androidx.compose.foundation.layout.RowScope import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import com.slapps.cupertino.CupertinoButton import com.slapps.cupertino.CupertinoButtonDefaults import com.slapps.cupertino.CupertinoIconButton import com.slapps.cupertino.ExperimentalCupertinoApi +import dev.dimension.flare.ui.theme.PlatformTheme @Composable internal actual fun PlatformButton( @@ -57,6 +59,42 @@ internal actual fun PlatformFilledTonalButton( ) } +@Composable +internal actual fun PlatformOutlinedButton( + onClick: () -> Unit, + modifier: Modifier, + enabled: Boolean, + content: @Composable (RowScope.() -> Unit), +) { + CupertinoButton( + onClick = onClick, + modifier = modifier, + content = content, + enabled = enabled, + colors = CupertinoButtonDefaults.tintedButtonColors(), + ) +} + +@Composable +internal actual fun PlatformErrorButton( + onClick: () -> Unit, + modifier: Modifier, + enabled: Boolean, + content: @Composable (RowScope.() -> Unit), +) { + CupertinoButton( + onClick = onClick, + modifier = modifier, + content = content, + enabled = enabled, + colors = + CupertinoButtonDefaults.filledButtonColors( + containerColor = PlatformTheme.colorScheme.error, + contentColor = Color.White, + ), + ) +} + @Composable internal actual fun PlatformIconButton( onClick: () -> Unit, diff --git a/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/controllers/ComposeUIHelper.kt b/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/controllers/ComposeUIHelper.kt index 07627503f..e4340dcf1 100644 --- a/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/controllers/ComposeUIHelper.kt +++ b/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/controllers/ComposeUIHelper.kt @@ -11,6 +11,7 @@ import dev.dimension.flare.common.SwiftOnDeviceAI import dev.dimension.flare.data.network.ktorClient import dev.dimension.flare.di.KoinHelper import dev.dimension.flare.ui.humanizer.SwiftFormatter +import dev.dimension.flare.ui.render.SwiftPlatformTextRenderer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -24,6 +25,7 @@ public object ComposeUIHelper { public fun initialize( inAppNotification: InAppNotification, swiftFormatter: SwiftFormatter, + swiftPlatformTextRenderer: SwiftPlatformTextRenderer, swiftOnDeviceAI: SwiftOnDeviceAI, ) { startKoin { @@ -39,6 +41,9 @@ public object ComposeUIHelper { single { swiftOnDeviceAI } bind SwiftOnDeviceAI::class + single { + swiftPlatformTextRenderer + } bind SwiftPlatformTextRenderer::class }, ) modules(dev.dimension.flare.di.composeUiModule) diff --git a/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/controllers/StatusController.kt b/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/controllers/StatusController.kt index 7e70e87cc..84cd771ea 100644 --- a/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/controllers/StatusController.kt +++ b/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/controllers/StatusController.kt @@ -55,7 +55,7 @@ import dev.dimension.flare.ui.component.floatingToolbarVerticalNestedScroll import dev.dimension.flare.ui.component.rememberPullToRefreshState import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid import dev.dimension.flare.ui.component.status.status -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.presenter.TimelineItemPresenterWithLazyListState import dev.dimension.flare.ui.theme.FlareTheme import kotlinx.coroutines.launch @@ -142,7 +142,7 @@ public fun TimelineItemController( @Suppress("FunctionName") public fun TimelineController( - state: ComposeUIStateProxy>, + state: ComposeUIStateProxy>, detailStatusKey: MicroBlogKey?, topPadding: Int, onExpand: () -> Unit, diff --git a/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.jvm.kt index b798f83f6..f54643497 100644 --- a/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.jvm.kt +++ b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.jvm.kt @@ -3,8 +3,14 @@ package dev.dimension.flare.ui.component.platform import androidx.compose.foundation.layout.RowScope import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import dev.dimension.flare.ui.theme.PlatformTheme +import io.github.composefluent.FluentTheme import io.github.composefluent.component.AccentButton import io.github.composefluent.component.Button +import io.github.composefluent.component.ButtonColor +import io.github.composefluent.component.ButtonDefaults import io.github.composefluent.component.SubtleButton @Composable @@ -52,6 +58,64 @@ internal actual fun PlatformFilledTonalButton( ) } +@Composable +internal actual fun PlatformOutlinedButton( + onClick: () -> Unit, + modifier: Modifier, + enabled: Boolean, + content: @Composable RowScope.() -> Unit, +) { + Button( + onClick = onClick, + modifier = modifier, + content = content, + disabled = !enabled, + ) +} + +@Composable +internal actual fun PlatformErrorButton( + onClick: () -> Unit, + modifier: Modifier, + enabled: Boolean, + content: @Composable RowScope.() -> Unit, +) { + val error = PlatformTheme.colorScheme.error + AccentButton( + onClick = onClick, + modifier = modifier, + content = content, + disabled = !enabled, + buttonColors = + ButtonDefaults.accentButtonColors( + default = + ButtonColor( + fillColor = error, + contentColor = Color.White, + borderBrush = FluentTheme.colors.borders.accentControl, + ), + hovered = + ButtonColor( + fillColor = error.copy(alpha = 0.92f), + contentColor = Color.White, + borderBrush = FluentTheme.colors.borders.accentControl, + ), + pressed = + ButtonColor( + fillColor = error.copy(alpha = 0.84f), + contentColor = Color.White, + borderBrush = FluentTheme.colors.borders.accentControl, + ), + disabled = + ButtonColor( + fillColor = error.copy(alpha = 0.4f), + contentColor = Color.White.copy(alpha = 0.7f), + borderBrush = SolidColor(Color.Transparent), + ), + ), + ) +} + @Composable internal actual fun PlatformIconButton( onClick: () -> Unit, diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index 210a4e475..e1f9954ee 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -360,9 +360,13 @@ Block user Are you sure you want to block this user? + Unblock user + Are you sure you want to unblock this user? Mute user Are you sure you want to mute this user? + Unmute user + Are you sure you want to unmute this user? Report user Are you sure you want to report this user? diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/AccountItem.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/AccountItem.kt index 1ab396fcf..484489fd4 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/AccountItem.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/AccountItem.kt @@ -36,7 +36,7 @@ fun AccountItem( RichText(text = it.name, maxLines = 1) }, supportingContent: @Composable (UiProfile) -> Unit = { - Text(text = it.handle, maxLines = 1) + Text(text = it.handle.canonical, maxLines = 1) }, avatarSize: Dp = 24.dp, ) { 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 50637eeea..fa6affd10 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 @@ -243,12 +243,24 @@ internal sealed interface Route : NavKey { val userKey: MicroBlogKey, ) : FloatingRoute + @Serializable + data class UnblockUser( + val accountType: AccountType?, + val userKey: MicroBlogKey, + ) : FloatingRoute + @Serializable data class MuteUser( val accountType: AccountType?, val userKey: MicroBlogKey, ) : FloatingRoute + @Serializable + data class UnmuteUser( + val accountType: AccountType?, + val userKey: MicroBlogKey, + ) : FloatingRoute + @Serializable data class ReportUser( val accountType: AccountType?, @@ -404,6 +416,11 @@ internal sealed interface Route : NavKey { accountType = deeplinkRoute.accountKey?.let { AccountType.Specific(it) }, userKey = deeplinkRoute.userKey, ) + is DeeplinkRoute.UnmuteUser -> + Route.UnmuteUser( + accountType = deeplinkRoute.accountKey?.let { AccountType.Specific(it) }, + userKey = deeplinkRoute.userKey, + ) is DeeplinkRoute.ReportUser -> Route.ReportUser( accountType = deeplinkRoute.accountKey?.let { AccountType.Specific(it) }, @@ -415,6 +432,11 @@ internal sealed interface Route : NavKey { accountType = deeplinkRoute.accountKey?.let { AccountType.Specific(it) }, userKey = deeplinkRoute.userKey, ) + is DeeplinkRoute.UnblockUser -> + Route.UnblockUser( + accountType = deeplinkRoute.accountKey?.let { AccountType.Specific(it) }, + userKey = deeplinkRoute.userKey, + ) } } } 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 519c25087..fbbaf2cd5 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 @@ -65,6 +65,8 @@ import dev.dimension.flare.ui.screen.home.ReportUserDialog import dev.dimension.flare.ui.screen.home.SearchScreen import dev.dimension.flare.ui.screen.home.TabSettingScreen import dev.dimension.flare.ui.screen.home.TimelineScreen +import dev.dimension.flare.ui.screen.home.UnblockUserDialog +import dev.dimension.flare.ui.screen.home.UnmuteUserDialog import dev.dimension.flare.ui.screen.list.AllListScreen import dev.dimension.flare.ui.screen.media.RawMediaScreen import dev.dimension.flare.ui.screen.media.StatusMediaScreen @@ -834,6 +836,24 @@ internal fun WindowScope.Router( onBack = onBack, ) } + entry( + metadata = dialog(), + ) { args -> + UnblockUserDialog( + accountType = args.accountType, + userKey = args.userKey, + onBack = onBack, + ) + } + entry( + metadata = dialog(), + ) { args -> + UnmuteUserDialog( + accountType = args.accountType, + userKey = args.userKey, + onBack = onBack, + ) + } entry( metadata = dialog(), ) { args -> diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt index b6f365116..2df8b9fc9 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt @@ -98,7 +98,7 @@ import dev.dimension.flare.ui.component.status.CommonStatusComponent import dev.dimension.flare.ui.component.status.StatusVisibilityComponent import dev.dimension.flare.ui.model.UiEmoji import dev.dimension.flare.ui.model.UiState -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.model.map import dev.dimension.flare.ui.model.mapNotNull import dev.dimension.flare.ui.model.onError @@ -236,7 +236,7 @@ fun ComposeDialog( }, content = { AvatarComponent(it.avatar, size = 24.dp) - Text(it.handle) + Text(it.handle.canonical) }, ) } @@ -250,7 +250,7 @@ fun ComposeDialog( user.onSuccess { data -> MenuFlyoutItem( text = { - Text(text = data.handle) + Text(text = data.handle.canonical) }, onClick = { state.state.selectAccount(account) @@ -600,8 +600,8 @@ fun ComposeDialog( state.state.replyState?.let { replyState -> replyState.onSuccess { state -> - val content = state.content - if (content is UiTimeline.ItemContent.Status) { + val content = state as? UiTimelineV2.Post + if (content is UiTimelineV2.Post) { Card( modifier = Modifier @@ -1023,7 +1023,7 @@ private fun composePresenter( ?.toString(), visibility = state.visibilityState.takeSuccess()?.visibility - ?: UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Public, + ?: UiTimelineV2.Post.Visibility.Public, account = it, referenceStatus = status?.let { @@ -1253,40 +1253,40 @@ internal enum class PollExpiration( Days7(Res.string.compose_poll_expiration_7_days, 7.days), } -internal val UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.localName: StringResource +internal val UiTimelineV2.Post.Visibility.localName: StringResource get() = when (this) { - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Public -> + UiTimelineV2.Post.Visibility.Public -> Res.string.misskey_visibility_public - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Home -> + UiTimelineV2.Post.Visibility.Home -> Res.string.misskey_visibility_home - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Followers -> + UiTimelineV2.Post.Visibility.Followers -> Res.string.misskey_visibility_followers - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Specified -> + UiTimelineV2.Post.Visibility.Specified -> Res.string.misskey_visibility_specified - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Channel -> + UiTimelineV2.Post.Visibility.Channel -> Res.string.misskey_visibility_public } -internal val UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.localDescription: StringResource +internal val UiTimelineV2.Post.Visibility.localDescription: StringResource get() = when (this) { - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Public -> + UiTimelineV2.Post.Visibility.Public -> Res.string.misskey_visibility_public_description - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Home -> + UiTimelineV2.Post.Visibility.Home -> Res.string.misskey_visibility_home_description - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Followers -> + UiTimelineV2.Post.Visibility.Followers -> Res.string.misskey_visibility_followers_description - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Specified -> + UiTimelineV2.Post.Visibility.Specified -> Res.string.misskey_visibility_specified_description - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Channel -> + UiTimelineV2.Post.Visibility.Channel -> Res.string.misskey_visibility_public_description } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/AddTabDialog.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/AddTabDialog.kt index fa8580b2a..1b98db145 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/AddTabDialog.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/AddTabDialog.kt @@ -144,7 +144,7 @@ internal fun AddTabDialog( overflow = TextOverflow.Ellipsis, ) Text( - text = tab.profile.handle, + text = tab.profile.handle.canonical, style = FluentTheme.typography.caption, color = if (pagerState.currentPage == index + 1) { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/BlockUserDialog.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/BlockUserDialog.kt index 131a3991d..64903043b 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/BlockUserDialog.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/BlockUserDialog.kt @@ -16,6 +16,12 @@ import dev.dimension.flare.report_user_description import dev.dimension.flare.report_user_title import dev.dimension.flare.ui.presenter.profile.BlockUserPresenter import dev.dimension.flare.ui.presenter.profile.MuteUserPresenter +import dev.dimension.flare.ui.presenter.profile.UnblockUserPresenter +import dev.dimension.flare.ui.presenter.profile.UnmuteUserPresenter +import dev.dimension.flare.unblock_user_description +import dev.dimension.flare.unblock_user_title +import dev.dimension.flare.unmute_user_description +import dev.dimension.flare.unmute_user_title import io.github.composefluent.component.ContentDialog import io.github.composefluent.component.ContentDialogButton import io.github.composefluent.component.Text @@ -87,6 +93,71 @@ internal fun MuteUserDialog( ) } +@Composable +internal fun UnblockUserDialog( + accountType: AccountType?, + userKey: MicroBlogKey, + onBack: () -> Unit, +) { + val state by producePresenter("unblock_user_${accountType}_$userKey") { + remember { + UnblockUserPresenter(accountType, userKey) + }.body() + } + ContentDialog( + title = stringResource(Res.string.unblock_user_title), + visible = true, + content = { + Text(stringResource(Res.string.unblock_user_description)) + }, + primaryButtonText = stringResource(Res.string.ok), + closeButtonText = stringResource(Res.string.cancel), + onButtonClick = { + when (it) { + ContentDialogButton.Primary -> { + state.confirm() + onBack.invoke() + } + ContentDialogButton.Secondary -> onBack.invoke() + ContentDialogButton.Close -> onBack.invoke() + } + }, + ) +} + +@Composable +internal fun UnmuteUserDialog( + accountType: AccountType?, + userKey: MicroBlogKey, + onBack: () -> Unit, +) { + val state by producePresenter("unmute_user_${accountType}_$userKey") { + remember { + UnmuteUserPresenter(accountType, userKey) + }.body() + } + + ContentDialog( + title = stringResource(Res.string.unmute_user_title), + visible = true, + content = { + Text(stringResource(Res.string.unmute_user_description)) + }, + primaryButtonText = stringResource(Res.string.ok), + closeButtonText = stringResource(Res.string.cancel), + onButtonClick = { + when (it) { + ContentDialogButton.Primary -> { + state.confirm() + onBack.invoke() + } + ContentDialogButton.Secondary -> onBack.invoke() + ContentDialogButton.Close -> onBack.invoke() + } + }, + ) +} + @Composable internal fun ReportUserDialog( accountType: AccountType?, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt index 5e7894567..a25778ef0 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt @@ -209,7 +209,7 @@ internal fun DiscoverScreen( size = AvatarComponentDefaults.compatSize, ) Text( - profile.handle, + profile.handle.canonical, maxLines = 1, modifier = Modifier.padding(start = 8.dp), ) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt index f31bdcfdb..7943297b2 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt @@ -57,7 +57,9 @@ internal fun NotificationScreen() { span = StaggeredGridItemSpan.FullLine, ) { LiteFilter { - state.notifications.forEach { (profile, badge) -> + state.notifications.forEach { item -> + val profile = item.profile + val badge = item.badge PillButton( selected = state.selectedAccount?.key == profile.key, onSelectedChanged = { @@ -74,7 +76,7 @@ internal fun NotificationScreen() { size = AvatarComponentDefaults.compatSize, ) Text( - profile.handle, + profile.handle.canonical, maxLines = 1, modifier = Modifier.padding(start = 8.dp), ) @@ -99,7 +101,7 @@ internal fun NotificationScreen() { span = StaggeredGridItemSpan.FullLine, ) { LiteFilter { - types.forEachIndexed { index, type -> + types.forEach { type -> PillButton( selected = state.selectedFilter == type, onSelectedChanged = { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt index f2ce86ea4..b320ce55a 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt @@ -60,7 +60,7 @@ import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid import dev.dimension.flare.ui.component.status.MediaItem import dev.dimension.flare.ui.component.status.StatusPlaceholder import dev.dimension.flare.ui.component.status.status -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.model.map import dev.dimension.flare.ui.model.onError import dev.dimension.flare.ui.model.onLoading @@ -333,8 +333,7 @@ internal fun ProfileScreen( vertical = 4.dp, ).clipToBounds() .clickable { - val content = item.status.content - if (content is UiTimeline.ItemContent.Status) { + if (item.status is UiTimelineV2.Post) { // onItemClicked( // content.statusKey, // item.index, @@ -481,7 +480,7 @@ private fun presenter( private sealed interface ProfileTabItem { data class Timeline( val type: ProfileTab.Timeline.Type, - val data: PagingState, + val data: PagingState, ) : ProfileTabItem data class Media( diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt index 12537cbb2..39d98efdb 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt @@ -203,7 +203,7 @@ fun SearchScreen( size = AvatarComponentDefaults.compatSize, ) Text( - profile.handle, + profile.handle.canonical, maxLines = 1, modifier = Modifier.padding(start = 8.dp), ) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt index aeb2384b1..52a783099 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt @@ -78,7 +78,7 @@ import dev.dimension.flare.ui.component.NetworkImage import dev.dimension.flare.ui.component.status.MediaItem import dev.dimension.flare.ui.humanizer.humanize import dev.dimension.flare.ui.model.UiMedia -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.model.getFileName import dev.dimension.flare.ui.model.map import dev.dimension.flare.ui.model.onSuccess @@ -581,7 +581,7 @@ private fun presenter( val medias = state.status.map { - (it.content as? UiTimeline.ItemContent.Status)?.images.orEmpty().toImmutableList() + (it as? UiTimelineV2.Post)?.images.orEmpty().toImmutableList() } medias.onSuccess { @@ -606,11 +606,10 @@ private fun presenter( } fun save(item: UiMedia) { - val status = (state.status.takeSuccess()?.content as? UiTimeline.ItemContent.Status) + val status = state.status.takeSuccess() as? UiTimelineV2.Post if (status != null) { - val statusKey = status.statusKey.toString() - val userHandle = status.user?.handle ?: "unknown" - val fileName = item.getFileName(statusKey, userHandle) + val userHandle = status.user?.handle?.canonical ?: "unknown" + val fileName = item.getFileName(statusKey.toString(), userHandle) when (item) { is UiMedia.Audio -> Unit diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt index df8afb7c8..8989a5065 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt @@ -274,7 +274,7 @@ internal fun SettingsScreen( AvatarComponent(data = activeAccount.avatar, size = 24.dp) }, caption = { - Text(text = activeAccount.handle) + Text(text = activeAccount.handle.canonical) }, ) { state.accountState.accounts.onSuccess { accounts -> diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/StatusScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/StatusScreen.kt index 05595bcef..7ce033c08 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/StatusScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/StatusScreen.kt @@ -21,7 +21,6 @@ import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid import dev.dimension.flare.ui.component.status.status -import dev.dimension.flare.ui.model.takeSuccess import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.status.StatusContextPresenter import io.github.composefluent.component.ProgressBar @@ -53,10 +52,7 @@ internal fun StatusScreen( ) { status( state.state.listState, - detailStatusKey = - state.state.current - .takeSuccess() - ?.statusKey, + detailStatusKey = statusKey, ) } if (state.isRefreshing) { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt index c982ca608..d1025a267 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt @@ -40,7 +40,7 @@ import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid import dev.dimension.flare.ui.component.status.StatusItem import dev.dimension.flare.ui.component.status.status import dev.dimension.flare.ui.model.UiState -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.model.onError import dev.dimension.flare.ui.model.onLoading import dev.dimension.flare.ui.model.onSuccess @@ -117,13 +117,13 @@ internal fun VVOStatusScreen( @Composable private fun StatusContent( - statusState: UiState, + statusState: UiState, detailStatusKey: MicroBlogKey, modifier: Modifier = Modifier, ) { statusState .onSuccess { status -> - key(status.itemKey, status.content) { + key(status.itemKey) { StatusItem( item = status, detailStatusKey = detailStatusKey, @@ -167,8 +167,8 @@ private fun StatusContent( } private fun LazyStaggeredGridScope.reactionContent( - comment: PagingState, - repost: PagingState, + comment: PagingState, + repost: PagingState, detailType: DetailType, onDetailTypeChange: (DetailType) -> Unit, ) { diff --git a/iosApp/flare/Common/PlatformTextRenderer.swift b/iosApp/flare/Common/PlatformTextRenderer.swift new file mode 100644 index 000000000..515c24431 --- /dev/null +++ b/iosApp/flare/Common/PlatformTextRenderer.swift @@ -0,0 +1,230 @@ +import SwiftUI +@preconcurrency import KotlinSharedUI + +class PlatformTextContent: NSObject {} + +final class PlatformTextTextContent: PlatformTextContent { + let runs: [PlatformTextRun] + + init(runs: [PlatformTextRun]) { + self.runs = runs + super.init() + } +} + +final class PlatformTextBlockImageContent: PlatformTextContent { + let url: String + let href: String? + + init(url: String, href: String?) { + self.url = url + self.href = href + super.init() + } +} + +class PlatformTextRun: NSObject {} + +final class PlatformTextAttributedRun: PlatformTextRun { + let text: AttributedString + + init(text: AttributedString) { + self.text = text + super.init() + } +} + +final class PlatformTextImageRun: PlatformTextRun { + let url: String + let alt: String + + init(url: String, alt: String) { + self.url = url + self.alt = alt + super.init() + } +} + +final class PlatformTextRenderer: SwiftPlatformTextRenderer { + static let shared = PlatformTextRenderer() + + private init() {} + + func render(richText: UiRichText) -> [Any] { + let context = RenderContext() + + func renderNode(_ node: KsoupNode) { + if let element = node as? KsoupElement { + renderElement(element) + } else if let textNode = node as? KsoupTextNode { + context.appendAttributedText( + AttributedString( + textNode.text(), + attributes: context.attributeContainer + ) + ) + } + } + + func renderChildren(of element: KsoupElement) { + element.childNodes().forEach { renderNode($0) } + } + + func renderElement(_ element: KsoupElement) { + switch element.tagName().lowercased() { + case "a": + let href = element.attribute(key: "href")?.value ?? "" + context.withAttributes { + $0.link = URL(string: href) + } render: { + renderChildren(of: element) + } + case "strong", "b": + context.withAttributes { + $0.font = .system(size: UIFont.systemFontSize, weight: .bold) + } render: { + renderChildren(of: element) + } + case "em", "i": + context.withAttributes { + $0.font = .system(size: UIFont.systemFontSize, weight: .regular).italic() + } render: { + renderChildren(of: element) + } + case "br": + context.appendAttributedText( + AttributedString( + "\n", + attributes: context.attributeContainer + ) + ) + case "p", "div": + renderChildren(of: element) + if element.parent()?.childNodes().last != element { + context.appendAttributedText( + AttributedString( + "\n\n", + attributes: context.attributeContainer + ) + ) + } + case "span": + renderChildren(of: element) + case "del", "s": + context.withAttributes { + $0.strikethroughStyle = .single + } render: { + renderChildren(of: element) + } + case "code": + context.withAttributes { + $0.font = .system(.body, design: .monospaced) + } render: { + renderChildren(of: element) + } + case "blockquote": + context.withAttributes { + $0.font = .system(size: UIFont.systemFontSize, weight: .regular).italic() + $0.foregroundColor = .secondary + } render: { + renderChildren(of: element) + } + case "u": + context.withAttributes { + $0.underlineStyle = .single + } render: { + renderChildren(of: element) + } + case "small": + context.withAttributes { + $0.font = .system(size: UIFont.smallSystemFontSize) + } render: { + renderChildren(of: element) + } + case "emoji": + context.appendImage( + url: element.attribute(key: "target")?.value ?? "", + alt: element.attribute(key: "alt")?.value ?? "" + ) + case "figure": + context.pushBlockState() + renderChildren(of: element) + context.popBlockState() + case "img": + let src = element.attribute(key: "src")?.value ?? "" + if context.isInBlockState { + context.appendBlockImage( + url: src, + href: element.attribute(key: "href")?.value + ) + } else { + context.appendImage( + url: src, + alt: element.attribute(key: "alt")?.value ?? "" + ) + } + default: + renderChildren(of: element) + } + } + + renderElement(richText.data) + context.flushTextContent() + return context.contents + } +} + +private final class RenderContext { + var contents: [PlatformTextContent] = [] + var currentRuns: [PlatformTextRun] = [] + var attributedString = AttributedString() + var attributeContainer = AttributeContainer() + private(set) var isInBlockState = false + + func appendAttributedText(_ text: AttributedString) { + attributedString = attributedString + text + } + + func appendImage(url: String, alt: String) { + commitAttributedString() + currentRuns.append(PlatformTextImageRun(url: url, alt: alt)) + } + + func appendBlockImage(url: String, href: String?) { + flushTextContent() + contents.append(PlatformTextBlockImageContent(url: url, href: href)) + } + + func flushTextContent() { + commitAttributedString() + guard !currentRuns.isEmpty else { return } + contents.append(PlatformTextTextContent(runs: currentRuns)) + currentRuns = [] + } + + func pushBlockState() { + isInBlockState = true + } + + func popBlockState() { + isInBlockState = false + } + + func withAttributes( + _ update: (inout AttributeContainer) -> Void, + render: () -> Void + ) { + let currentAttributes = attributeContainer + var nextAttributes = currentAttributes + update(&nextAttributes) + attributeContainer = nextAttributes + render() + attributeContainer = currentAttributes + } + + private func commitAttributedString() { + guard attributedString.characters.count > 0 else { return } + currentRuns.append(PlatformTextAttributedRun(text: attributedString)) + attributedString = AttributedString() + } +} diff --git a/iosApp/flare/FlareApp.swift b/iosApp/flare/FlareApp.swift index 4047a583d..402013123 100644 --- a/iosApp/flare/FlareApp.swift +++ b/iosApp/flare/FlareApp.swift @@ -9,6 +9,7 @@ struct FlareApp: App { ComposeUIHelper.shared.initialize( inAppNotification: SwiftInAppNotification.shared, swiftFormatter: Formatter.shared, + swiftPlatformTextRenderer: PlatformTextRenderer.shared, swiftOnDeviceAI: FoundationModelOnDeviceAI.shared ) } diff --git a/iosApp/flare/Localizable.xcstrings b/iosApp/flare/Localizable.xcstrings index 610c574d5..87ad3ed74 100644 --- a/iosApp/flare/Localizable.xcstrings +++ b/iosApp/flare/Localizable.xcstrings @@ -72,16 +72,16 @@ "value" : "読み込み中" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Laden..." + "value" : "Laster" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Laster" + "value" : "Laden..." } }, "pl" : { @@ -133,6 +133,9 @@ } } } + }, + "%d" : { + }, "%lld" : { "localizations" : { @@ -190,13 +193,13 @@ "value" : "%lld" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "%lld" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "%lld" @@ -314,13 +317,13 @@ "value" : "%1$lld/%2$d" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld/%2$d" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld/%2$d" @@ -438,13 +441,13 @@ "value" : "%1$lld/%2$lld" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld/%2$lld" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld/%2$lld" @@ -598,16 +601,16 @@ "value" : "Flare에 대해 더 알아보기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Meer informatie over Flare" + "value" : "Lær mer om Flare" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lær mer om Flare" + "value" : "Meer informatie over Flare" } }, "pl" : { @@ -788,16 +791,16 @@ "value" : "정보" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Informatie" + "value" : "Om" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Om" + "value" : "Informatie" } }, "pl" : { @@ -942,16 +945,16 @@ "value" : "フォローリクエストを承認する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Accepteer volgverzoek" + "value" : "Godta følg-forespørsel" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Godta følg-forespørsel" + "value" : "Accepteer volgverzoek" } }, "pl" : { @@ -1078,16 +1081,16 @@ "value" : "アカウントを管理する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beheer je accounts" + "value" : "Administrer kontoene dine" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Administrer kontoene dine" + "value" : "Beheer je accounts" } }, "pl" : { @@ -1208,16 +1211,16 @@ "value" : "アカウント管理" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Account beheer" + "value" : "Konto administrasjon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Konto administrasjon" + "value" : "Account beheer" } }, "pl" : { @@ -1380,16 +1383,16 @@ "value" : "재시도" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opnieuw" + "value" : "Prøv igjen" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Prøv igjen" + "value" : "Opnieuw" } }, "pl" : { @@ -1570,16 +1573,16 @@ "value" : "추가" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toevoegen" + "value" : "Legg til" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Legg til" + "value" : "Toevoegen" } }, "pl" : { @@ -1730,16 +1733,16 @@ "value" : "RSSを追加" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "RSS toevoegen" + "value" : "Legg til RSS" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Legg til RSS" + "value" : "RSS toevoegen" } }, "pl" : { @@ -1800,131 +1803,11 @@ }, "AI Type" : { "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI Type" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI Type" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI Type" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI Type" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI Type" - } - }, "en" : { "stringUnit" : { "state" : "translated", "value" : "AI Type" } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI Type" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI Type" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI Type" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI Type" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI Type" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI Type" - } - }, - "no" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI Type" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI Type" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI Type" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI Type" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI Type" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI Type" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI Type" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI Type" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI Type" - } } } }, @@ -2032,16 +1915,16 @@ "value" : "AI 설정 구성" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Configureer AI instellingen" + "value" : "Konfigurer AI-innstillinger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Konfigurer AI-innstillinger" + "value" : "Configureer AI instellingen" } }, "pl" : { @@ -2193,16 +2076,16 @@ "value" : "AI 機能" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "AI functies" + "value" : "Egenskaper av AI" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Egenskaper av AI" + "value" : "AI functies" } }, "pl" : { @@ -2360,16 +2243,16 @@ "value" : "서버 URL을 입력하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voer de server-URL in" + "value" : "Skriv inn nettadressen til serveren" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Skriv inn nettadressen til serveren" + "value" : "Voer de server-URL in" } }, "pl" : { @@ -2551,16 +2434,16 @@ "value" : "서버 URL" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Server URL" + "value" : "URL til server" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "URL til server" + "value" : "Server URL" } }, "pl" : { @@ -2741,16 +2624,16 @@ "value" : "AI 요약 기능 활성화" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "AI samenvatting inschakelen" + "value" : "Aktiver AI sammendrag" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aktiver AI sammendrag" + "value" : "AI samenvatting inschakelen" } }, "pl" : { @@ -2931,16 +2814,16 @@ "value" : "AI 구성" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "AI configuratie" + "value" : "AI Konfigurasjon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "AI Konfigurasjon" + "value" : "AI configuratie" } }, "pl" : { @@ -3121,16 +3004,16 @@ "value" : "AI 구성" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "AI configuratie" + "value" : "Aktiver AI-oversettelse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aktiver AI-oversettelse" + "value" : "AI configuratie" } }, "pl" : { @@ -3311,16 +3194,16 @@ "value" : "발견하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ontdek feeds" + "value" : "Oppdag fôr" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Oppdag fôr" + "value" : "Ontdek feeds" } }, "pl" : { @@ -3501,16 +3384,16 @@ "value" : "내 피드" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Mijn feeds" + "value" : "Mine fôr" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Mine fôr" + "value" : "Mijn feeds" } }, "pl" : { @@ -3661,16 +3544,16 @@ "value" : "すべてのフィード" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Alle feeds" + "value" : "Alle kanaler" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Alle kanaler" + "value" : "Alle feeds" } }, "pl" : { @@ -3827,16 +3710,16 @@ "value" : "목록" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Klantenlijst" + "value" : "Liste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liste" + "value" : "Klantenlijst" } }, "pl" : { @@ -3981,16 +3864,16 @@ "value" : "すべてのRSSフィード" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Alle RSS feeds" + "value" : "Alle RSS-Feeds" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Alle RSS-Feeds" + "value" : "Alle RSS feeds" } }, "pl" : { @@ -4105,13 +3988,13 @@ "value" : "ALT" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "ALT" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "ALT" @@ -4271,13 +4154,13 @@ "value" : "안테나" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Antenne" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Antenne" @@ -4461,13 +4344,13 @@ "value" : "안테나" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Antenne" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Antenne" @@ -4549,131 +4432,11 @@ }, "API Key" : { "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "مفتاح API" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "API klíč" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "API Nøgle" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "API-Schlüssel" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Κλειδί API" - } - }, "en" : { "stringUnit" : { "state" : "translated", "value" : "API Key" } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Clave API" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Api Avain" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Clé API" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chiave API" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "API キー" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "API Sleutel" - } - }, - "no" : { - "stringUnit" : { - "state" : "translated", - "value" : "API-nøkkel (Automatic Translation)" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Klucz API" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chave de API" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chave de API" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cheie API" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ключ API" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "API nyckel" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "API ключ" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "API 密钥" - } } } }, @@ -4775,16 +4538,16 @@ "value" : "앱 로깅" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "App logboek" + "value" : "Logg app" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg app" + "value" : "App logboek" } }, "pl" : { @@ -4959,16 +4722,16 @@ "value" : "네트워크 로깅 활성화" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Netwerk loggen inschakelen" + "value" : "Aktiver nettverkslogging" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aktiver nettverkslogging" + "value" : "Netwerk loggen inschakelen" } }, "pl" : { @@ -5113,16 +4876,16 @@ "value" : "絶対タイムスタンプです" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Absolute tijdstempel" + "value" : "Absolutt tidsstempel" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Absolutt tidsstempel" + "value" : "Absolute tijdstempel" } }, "pl" : { @@ -5161,12 +4924,6 @@ "value" : "Absolut tidsstämpel" } }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mutlak zaman damgası" - } - }, "uk" : { "stringUnit" : { "state" : "translated", @@ -5249,16 +5006,16 @@ "value" : "投稿に絶対タイムスタンプを表示する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Geef absolute tijdsaanduiding weer bij berichten" + "value" : "Vis absolutte tidsstempler på innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis absolutte tidsstempler på innlegg" + "value" : "Geef absolute tijdsaanduiding weer bij berichten" } }, "pl" : { @@ -5297,12 +5054,6 @@ "value" : "Visa absoluta tidsstämplar på inlägg" } }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gönderilere mutlak zaman damgaları ekle" - } - }, "uk" : { "stringUnit" : { "state" : "translated", @@ -5421,16 +5172,16 @@ "value" : "아바타 모양" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vorm avatar" + "value" : "Profilbilde form" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Profilbilde form" + "value" : "Vorm avatar" } }, "pl" : { @@ -5611,16 +5362,16 @@ "value" : "둥글게" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ronde" + "value" : "Rund" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rund" + "value" : "Ronde" } }, "pl" : { @@ -5801,16 +5552,16 @@ "value" : "아바타의 모양을 변경합니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "De vorm van de avatar wijzigen" + "value" : "Endre formen på avataren" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Endre formen på avataren" + "value" : "De vorm van de avatar wijzigen" } }, "pl" : { @@ -5991,16 +5742,16 @@ "value" : "정사각형" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vierkant" + "value" : "Firkant" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Firkant" + "value" : "Vierkant" } }, "pl" : { @@ -6175,16 +5926,16 @@ "value" : "링크 미리보기 표시" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toon linkvoorbeelden" + "value" : "Link forhåndsvisninger i Compat modus" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Link forhåndsvisninger i Compat modus" + "value" : "Toon linkvoorbeelden" } }, "pl" : { @@ -6329,16 +6080,16 @@ "value" : "投稿にシンプルモードでリンクのプレビューを表示する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Geef voorbeeld van de link weer in de vereenvoudiging modus van de post" + "value" : "Vis forhåndsvisning av lenker i forenkle modus i innlegget" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis forhåndsvisning av lenker i forenkle modus i innlegget" + "value" : "Geef voorbeeld van de link weer in de vereenvoudiging modus van de post" } }, "pl" : { @@ -6495,16 +6246,16 @@ "value" : "Flare의 모양과 느낌을_customize합니다." } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Pas het uiterlijk en gevoel van Flare aan" + "value" : "Tilpass utseendet og følelsen til flare" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tilpass utseendet og følelsen til flare" + "value" : "Pas het uiterlijk en gevoel van Flare aan" } }, "pl" : { @@ -6685,16 +6436,16 @@ "value" : "미디어를 전체 크기로 확장" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Media uitbreiden naar volledige grootte" + "value" : "Utvid media til full størrelse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Utvid media til full størrelse" + "value" : "Media uitbreiden naar volledige grootte" } }, "pl" : { @@ -6869,16 +6620,16 @@ "value" : "타임라인의 미디어 비율 유지" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bewaar het aspect van de media op de tijdlijn" + "value" : "Hold medias erfaring i tidslinje" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hold medias erfaring i tidslinje" + "value" : "Bewaar het aspect van de media op de tijdlijn" } }, "pl" : { @@ -7023,13 +6774,13 @@ "value" : "Font Size" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Font Size" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Font Size" @@ -7159,16 +6910,16 @@ "value" : "横広の投稿" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volledige breedte bericht" + "value" : "Full bredde innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Full bredde innlegg" + "value" : "Volledige breedte bericht" } }, "pl" : { @@ -7207,12 +6958,6 @@ "value" : "Full bredd inlägg" } }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tam genişlikte gönderi" - } - }, "uk" : { "stringUnit" : { "state" : "translated", @@ -7295,16 +7040,16 @@ "value" : "投稿を横広く表示する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toon bericht in volledige breedte" + "value" : "Vis innholdet i full bredde" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis innholdet i full bredde" + "value" : "Toon bericht in volledige breedte" } }, "pl" : { @@ -7343,12 +7088,6 @@ "value" : "Visa inlägget i full bredd" } }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gönderiyi tam genişlikte göster" - } - }, "uk" : { "stringUnit" : { "state" : "translated", @@ -7431,16 +7170,16 @@ "value" : "appearance_post_action_style" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "verschijn_post_action_stijl" + "value" : "Publiser handlingsstil" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Publiser handlingsstil" + "value" : "verschijn_post_action_stijl" } }, "pl" : { @@ -7561,16 +7300,16 @@ "value" : "投稿のアクションのスタイルを変更する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Wijzig de stijl van de actie van het bericht" + "value" : "Endre stilen til innleggets handling" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Endre stilen til innleggets handling" + "value" : "Wijzig de stijl van de actie van het bericht" } }, "pl" : { @@ -7691,13 +7430,13 @@ "value" : "Hidden" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Hidden" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Hidden" @@ -7821,16 +7560,16 @@ "value" : "左揃え" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Links uitgelijnd" + "value" : "Venstre justert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Venstre justert" + "value" : "Links uitgelijnd" } }, "pl" : { @@ -7951,16 +7690,16 @@ "value" : "右揃え" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Rechts uitgelijnd" + "value" : "Høyre justert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Høyre justert" + "value" : "Rechts uitgelijnd" } }, "pl" : { @@ -8081,16 +7820,16 @@ "value" : "ストレッチ" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Uitrekken" + "value" : "Strekk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Strekk" + "value" : "Uitrekken" } }, "pl" : { @@ -8247,16 +7986,16 @@ "value" : "링크 미리보기 표시" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toon linkvoorbeelden" + "value" : "Vis forhåndsvisning av lenker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis forhåndsvisning av lenker" + "value" : "Toon linkvoorbeelden" } }, "pl" : { @@ -8401,16 +8140,16 @@ "value" : "投稿にリンクのプレビューを表示する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toon linkvoorbeelden in het bericht" + "value" : "Vis forhåndsvisning av lenker i innlegget" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis forhåndsvisning av lenker i innlegget" + "value" : "Toon linkvoorbeelden in het bericht" } }, "pl" : { @@ -8567,16 +8306,16 @@ "value" : "미디어 표시" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Media tonen" + "value" : "Vis media" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis media" + "value" : "Media tonen" } }, "pl" : { @@ -8721,16 +8460,16 @@ "value" : "投稿にメディアを表示する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Media in het bericht weergeven" + "value" : "Vis media i innlegget" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis media i innlegget" + "value" : "Media in het bericht weergeven" } }, "pl" : { @@ -8887,16 +8626,16 @@ "value" : "숫자 표시" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Getallen weergeven" + "value" : "Vis tall" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis tall" + "value" : "Getallen weergeven" } }, "pl" : { @@ -9041,16 +8780,16 @@ "value" : "投稿の下部に番号を表示する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Getallen aan de onderkant van het bericht weergeven" + "value" : "Vis tall på bunnen av innlegget" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis tall på bunnen av innlegget" + "value" : "Getallen aan de onderkant van het bericht weergeven" } }, "pl" : { @@ -9171,16 +8910,16 @@ "value" : "プラットフォームのロゴを表示" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toon platform logo" + "value" : "Vis plattformlogo" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis plattformlogo" + "value" : "Toon platform logo" } }, "pl" : { @@ -9301,16 +9040,16 @@ "value" : "投稿にソース・プラットフォームのロゴを表示する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toon het bronlogo van het platform op post" + "value" : "Vis logo for kildeplattformplattformen på innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis logo for kildeplattformplattformen på innlegg" + "value" : "Toon het bronlogo van het platform op post" } }, "pl" : { @@ -9467,16 +9206,16 @@ "value" : "민감한 콘텐츠 표시" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gevoelige inhoud weergeven" + "value" : "Vis sensitivt innhold" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis sensitivt innhold" + "value" : "Gevoelige inhoud weergeven" } }, "pl" : { @@ -9651,16 +9390,16 @@ "value" : "상태에서 항상 민감한 콘텐츠 표시" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gevoelige inhoud altijd in status weergeven" + "value" : "Vis alltid sensitivt innhold i innlegget" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis alltid sensitivt innhold i innlegget" + "value" : "Gevoelige inhoud altijd in status weergeven" } }, "pl" : { @@ -9841,16 +9580,16 @@ "value" : "테마" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Thema" + "value" : "Tema" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tema" + "value" : "Thema" } }, "pl" : { @@ -10031,16 +9770,16 @@ "value" : "어두운 색" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Donker" + "value" : "Mørk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Mørk" + "value" : "Donker" } }, "pl" : { @@ -10221,16 +9960,16 @@ "value" : "앱의 테마 변경" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verander het thema van de app" + "value" : "Endre temaet for appen" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Endre temaet for appen" + "value" : "Verander het thema van de app" } }, "pl" : { @@ -10411,16 +10150,16 @@ "value" : "밝은 색" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Licht" + "value" : "Lys" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lys" + "value" : "Licht" } }, "pl" : { @@ -10595,16 +10334,16 @@ "value" : "시스템" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Systeem" + "value" : "Systemadministrasjon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Systemadministrasjon" + "value" : "Systeem" } }, "pl" : { @@ -10785,16 +10524,16 @@ "value" : "모양" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Uiterlijk" + "value" : "Utseende" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Utseende" + "value" : "Uiterlijk" } }, "pl" : { @@ -10939,16 +10678,16 @@ "value" : "実験的:クロスプラットフォーム投稿UIを使用" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Experimenteel: Gebruik cross-platform post UI" + "value" : "Eksperimentelt: bruk grensesnittet på kryss-plattformen" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Eksperimentelt: bruk grensesnittet på kryss-plattformen" + "value" : "Experimenteel: Gebruik cross-platform post UI" } }, "pl" : { @@ -11069,16 +10808,16 @@ "value" : "Android と Desktop から同じポスト UI コードを使用してください。これは実験的なものであり、将来的に削除される可能性があります。" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gebruik dezelfde gebruikersinterface code van Android en Desktop, dit is experimenteel en kan in de toekomst worden verwijderd." + "value" : "Bruk samme innlegg UI kode fra Android og Desktop, dette er eksperimentell og kan bli fjernet i fremtiden." } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Bruk samme innlegg UI kode fra Android og Desktop, dette er eksperimentell og kan bli fjernet i fremtiden." + "value" : "Gebruik dezelfde gebruikersinterface code van Android en Desktop, dit is experimenteel en kan in de toekomst worden verwijderd." } }, "pl" : { @@ -11235,16 +10974,16 @@ "value" : "비디오 자동 재생" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Video automatisch afspelen" + "value" : "Video autokjør" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Video autokjør" + "value" : "Video automatisch afspelen" } }, "pl" : { @@ -11425,16 +11164,16 @@ "value" : "항상" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "altijd" + "value" : "Alltid" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Alltid" + "value" : "altijd" } }, "pl" : { @@ -11579,16 +11318,16 @@ "value" : "投稿内の動画を自動的に再生" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Video's automatisch afspelen in het bericht" + "value" : "Automatisk spill av videoer i innlegget" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Automatisk spill av videoer i innlegget" + "value" : "Video's automatisch afspelen in het bericht" } }, "pl" : { @@ -11745,16 +11484,16 @@ "value" : "절대" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Nooit" + "value" : "Aldri" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aldri" + "value" : "Nooit" } }, "pl" : { @@ -11935,16 +11674,16 @@ "value" : "Wi-Fi 전용" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Alleen wifi" + "value" : "Kun Wi-Fi" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kun Wi-Fi" + "value" : "Alleen wifi" } }, "pl" : { @@ -12125,16 +11864,16 @@ "value" : "차단" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Blokkeren" + "value" : "Blokker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Blokker" + "value" : "Blokkeren" } }, "pl" : { @@ -12279,16 +12018,16 @@ "value" : "このユーザーをブロックしてもよろしいですか?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Weet je zeker dat je deze gebruiker wilt blokkeren?" + "value" : "Er du sikker på at du vil blokkere denne brukeren?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Er du sikker på at du vil blokkere denne brukeren?" + "value" : "Weet je zeker dat je deze gebruiker wilt blokkeren?" } }, "pl" : { @@ -12409,16 +12148,16 @@ "value" : "ユーザーをブロック" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gebruiker blokkeren" + "value" : "Blokker bruker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Blokker bruker" + "value" : "Gebruiker blokkeren" } }, "pl" : { @@ -12699,16 +12438,16 @@ "value" : "피드" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Feeds" + "value" : "Strøm" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Strøm" + "value" : "Feeds" } }, "pl" : { @@ -12786,6 +12525,7 @@ } }, "bluesky_notification_follow" : { + "extractionState" : "stale", "localizations" : { "af" : { "stringUnit" : { @@ -12889,16 +12629,16 @@ "value" : "당신을 팔로우했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "volgt jou nu" + "value" : "fulgte deg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "fulgte deg" + "value" : "volgt jou nu" } }, "pl" : { @@ -12976,6 +12716,7 @@ } }, "bluesky_notification_item_pin" : { + "extractionState" : "stale", "localizations" : { "af" : { "stringUnit" : { @@ -13079,16 +12820,16 @@ "value" : "고정됨" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vastgezet" + "value" : "Festet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Festet" + "value" : "Vastgezet" } }, "pl" : { @@ -13166,6 +12907,7 @@ } }, "bluesky_notification_like" : { + "extractionState" : "stale", "localizations" : { "af" : { "stringUnit" : { @@ -13269,16 +13011,16 @@ "value" : "좋아요를 눌렀습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "favoriet" + "value" : "favorisert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "favorisert" + "value" : "favoriet" } }, "pl" : { @@ -13356,6 +13098,7 @@ } }, "bluesky_notification_mention" : { + "extractionState" : "stale", "localizations" : { "af" : { "stringUnit" : { @@ -13459,16 +13202,16 @@ "value" : "당신을 언급했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "noemde je" + "value" : "nevnte deg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "nevnte deg" + "value" : "noemde je" } }, "pl" : { @@ -13546,6 +13289,7 @@ } }, "bluesky_notification_quote" : { + "extractionState" : "stale", "localizations" : { "af" : { "stringUnit" : { @@ -13649,16 +13393,16 @@ "value" : "인용했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "geciteerd" + "value" : "sitert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "sitert" + "value" : "geciteerd" } }, "pl" : { @@ -13736,6 +13480,7 @@ } }, "bluesky_notification_reply" : { + "extractionState" : "stale", "localizations" : { "af" : { "stringUnit" : { @@ -13839,16 +13584,16 @@ "value" : "당신에게 답글을 달았습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "heeft u geantwoord" + "value" : "svarte deg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "svarte deg" + "value" : "heeft u geantwoord" } }, "pl" : { @@ -13926,6 +13671,7 @@ } }, "bluesky_notification_repost" : { + "extractionState" : "stale", "localizations" : { "af" : { "stringUnit" : { @@ -14023,16 +13769,16 @@ "value" : "상태를 부스트했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "boostte een status" + "value" : "repostet et innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "repostet et innlegg" + "value" : "boostte een status" } }, "pl" : { @@ -14207,16 +13953,16 @@ "value" : "스타터팩에 가입했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Starterpack toegetreden" + "value" : "Starterpack er medlem" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Starterpack er medlem" + "value" : "Starterpack toegetreden" } }, "pl" : { @@ -14294,6 +14040,7 @@ } }, "bluesky_notification_unKnown" : { + "extractionState" : "stale", "localizations" : { "af" : { "stringUnit" : { @@ -14391,16 +14138,16 @@ "value" : "알 수 없음" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Onbekend" + "value" : "Ukjent" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ukjent" + "value" : "Onbekend" } }, "pl" : { @@ -14581,16 +14328,16 @@ "value" : "신고하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporteren" + "value" : "Rapporter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporter" + "value" : "Rapporteren" } }, "pl" : { @@ -14765,16 +14512,16 @@ "value" : "이 게시물의 문제는 무엇인가요?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Wat is het probleem met dit bericht?" + "value" : "Hva er problemet med dette innlegget?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hva er problemet med dette innlegget?" + "value" : "Wat is het probleem met dit bericht?" } }, "pl" : { @@ -14949,16 +14696,16 @@ "value" : "오해의 소지가 있는" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Misleidend" + "value" : "Villende" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Villende" + "value" : "Misleidend" } }, "pl" : { @@ -15133,16 +14880,16 @@ "value" : "이 게시물은 오해의 소지가 있습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Deze post is misleidend" + "value" : "Dette innlegget er misvisende" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Dette innlegget er misvisende" + "value" : "Deze post is misleidend" } }, "pl" : { @@ -15317,16 +15064,16 @@ "value" : "기타" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "anders" + "value" : "Annet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Annet" + "value" : "anders" } }, "pl" : { @@ -15501,16 +15248,16 @@ "value" : "이 옵션에 포함되지 않은 문제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Een probleem niet opgenomen in deze opties" + "value" : "Et problem er ikke inkludert i disse alternativene" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Et problem er ikke inkludert i disse alternativene" + "value" : "Een probleem niet opgenomen in deze opties" } }, "pl" : { @@ -15685,16 +15432,16 @@ "value" : "반사회적 행동" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Anti-sociaal gedrag" + "value" : "Anti-Social Athavior" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Anti-Social Athavior" + "value" : "Anti-sociaal gedrag" } }, "pl" : { @@ -15869,16 +15616,16 @@ "value" : "괴롭힘, 트롤링 또는 편견" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Pesterij, trollen of intolerantie" + "value" : "Trakassering, kontroll eller intoleranse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Trakassering, kontroll eller intoleranse" + "value" : "Pesterij, trollen of intolerantie" } }, "pl" : { @@ -16053,16 +15800,16 @@ "value" : "원치 않는 성적 콘텐츠" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ongewenste seksuele inhoud" + "value" : "Uønsket suell innhold" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Uønsket suell innhold" + "value" : "Ongewenste seksuele inhoud" } }, "pl" : { @@ -16237,16 +15984,16 @@ "value" : "라벨이 없는 누드 또는 포르노그래피" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voedingsstoffen of pornografie niet als zodanig geëtiketteerd" + "value" : "Nudity eller pornografi som ikke er merket slik" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nudity eller pornografi som ikke er merket slik" + "value" : "Voedingsstoffen of pornografie niet als zodanig geëtiketteerd" } }, "pl" : { @@ -16427,16 +16174,16 @@ "value" : "스팸" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Spam" + "value" : "Søppelpost" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Søppelpost" + "value" : "Spam" } }, "pl" : { @@ -16611,16 +16358,16 @@ "value" : "과도한 멘션이나 답글" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Overmatige vermeldingen of reacties" + "value" : "Overdreven omtale eller svar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Overdreven omtale eller svar" + "value" : "Overmatige vermeldingen of reacties" } }, "pl" : { @@ -16795,16 +16542,16 @@ "value" : "불법 및 긴급" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Illegaal en urgent" + "value" : "Ulovlig og Haster" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ulovlig og Haster" + "value" : "Illegaal en urgent" } }, "pl" : { @@ -16979,16 +16726,16 @@ "value" : "법률 또는 서비스 조건의 명백한 위반" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ernstige schendingen van de wet of de gebruiksvoorwaarden" + "value" : "Anskaffelse av lovbrudd eller tjenestevilkår" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Anskaffelse av lovbrudd eller tjenestevilkår" + "value" : "Ernstige schendingen van de wet of de gebruiksvoorwaarden" } }, "pl" : { @@ -17169,16 +16916,16 @@ "value" : "북마크 추가" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bladwijzer toevoegen" + "value" : "Legg til bokmerke" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Legg til bokmerke" + "value" : "Bladwijzer toevoegen" } }, "pl" : { @@ -17359,16 +17106,16 @@ "value" : "북마크 제거" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bladwijzer verwijderen" + "value" : "Fjern bokmerke" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Fjern bokmerke" + "value" : "Bladwijzer verwijderen" } }, "pl" : { @@ -17549,16 +17296,16 @@ "value" : "취소" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "annuleren" + "value" : "Avbryt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Avbryt" + "value" : "annuleren" } }, "pl" : { @@ -17703,16 +17450,16 @@ "value" : "チャンネル" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Kanaal" + "value" : "Kanal" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kanal" + "value" : "Kanaal" } }, "pl" : { @@ -17833,16 +17580,16 @@ "value" : "チャンネル" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Kanalen" + "value" : "Kanaler" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kanaler" + "value" : "Kanalen" } }, "pl" : { @@ -17881,12 +17628,6 @@ "value" : "Kanaler" } }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kanallar" - } - }, "uk" : { "stringUnit" : { "state" : "translated", @@ -18005,16 +17746,16 @@ "value" : "닫기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Afsluiten" + "value" : "Lukk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lukk" + "value" : "Afsluiten" } }, "pl" : { @@ -18195,16 +17936,16 @@ "value" : "댓글" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opmerking" + "value" : "Kommentar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kommentar" + "value" : "Opmerking" } }, "pl" : { @@ -18355,13 +18096,13 @@ "value" : "Cnacel" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Cnacel" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Cnacel" @@ -18521,16 +18262,16 @@ "value" : "보내기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verzenden" + "value" : "Sende" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sende" + "value" : "Verzenden" } }, "pl" : { @@ -18711,16 +18452,16 @@ "value" : "컨텐츠 경고" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Waarschuwing inhoud" + "value" : "Advarsel for innhold" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Advarsel for innhold" + "value" : "Waarschuwing inhoud" } }, "pl" : { @@ -18901,16 +18642,16 @@ "value" : "미디어를 민감한 내용으로 표시하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Media als gevoelig markeren" + "value" : "Merk media som sensitivt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Merk media som sensitivt" + "value" : "Media als gevoelig markeren" } }, "pl" : { @@ -19091,16 +18832,16 @@ "value" : "무슨 일이 일어나고 있나요?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Wat gebeurt er?" + "value" : "Hva skjer?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hva skjer?" + "value" : "Wat gebeurt er?" } }, "pl" : { @@ -19245,16 +18986,16 @@ "value" : "Option" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Optie" + "value" : "Alternativ" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Alternativ" + "value" : "Optie" } }, "pl" : { @@ -19381,16 +19122,16 @@ "value" : "有効期限:" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vervaldatum op:" + "value" : "Utløp på:" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Utløp på:" + "value" : "Vervaldatum op:" } }, "pl" : { @@ -19511,16 +19252,16 @@ "value" : "アンケートタイプ" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Poll type" + "value" : "Avstemnings type" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Avstemnings type" + "value" : "Poll type" } }, "pl" : { @@ -19677,16 +19418,16 @@ "value" : "복수 선택" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Meerdere keuzes" + "value" : "Flere valg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Flere valg" + "value" : "Meerdere keuzes" } }, "pl" : { @@ -19867,16 +19608,16 @@ "value" : "단일 선택" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Enkele keuze" + "value" : "Ett valg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ett valg" + "value" : "Enkele keuze" } }, "pl" : { @@ -20057,16 +19798,16 @@ "value" : "작성하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Samenstellen" + "value" : "Skriv" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Skriv" + "value" : "Samenstellen" } }, "pl" : { @@ -20247,16 +19988,16 @@ "value" : "인용하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Offerte" + "value" : "Sitat" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sitat" + "value" : "Offerte" } }, "pl" : { @@ -20437,16 +20178,16 @@ "value" : "답글" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beantwoorden" + "value" : "Svar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Svar" + "value" : "Beantwoorden" } }, "pl" : { @@ -20627,16 +20368,16 @@ "value" : "어두운 색" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Donker" + "value" : "Mørk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Mørk" + "value" : "Donker" } }, "pl" : { @@ -20817,16 +20558,16 @@ "value" : "브라우저에서 열기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Openen in browser" + "value" : "Åpne i nettleser" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Åpne i nettleser" + "value" : "Openen in browser" } }, "pl" : { @@ -20971,16 +20712,16 @@ "value" : "アカウントを選択" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Selecteer account" + "value" : "Velg konto" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Velg konto" + "value" : "Selecteer account" } }, "pl" : { @@ -21137,16 +20878,16 @@ "value" : "삭제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijderen" + "value" : "Slett" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett" + "value" : "Verwijderen" } }, "pl" : { @@ -21327,16 +21068,16 @@ "value" : "삭제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijderen" + "value" : "Slett" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett" + "value" : "Verwijderen" } }, "pl" : { @@ -21517,16 +21258,16 @@ "value" : "삭제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijderen" + "value" : "Slett" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett" + "value" : "Verwijderen" } }, "pl" : { @@ -21671,16 +21412,16 @@ "value" : "このリストを削除してもよろしいですか?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Weet u zeker dat u deze lijst wilt verwijderen?" + "value" : "Er du sikker på at du vil slette denne listen?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Er du sikker på at du vil slette denne listen?" + "value" : "Weet u zeker dat u deze lijst wilt verwijderen?" } }, "pl" : { @@ -21837,16 +21578,16 @@ "value" : "목록 삭제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lijst verwijderen" + "value" : "Slett liste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett liste" + "value" : "Lijst verwijderen" } }, "pl" : { @@ -22021,16 +21762,16 @@ "value" : "정말로 이것을 삭제하시겠습니까?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Weet u zeker dat u dit wilt verwijderen?" + "value" : "Er du sikker på at du vil slette dette?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Er du sikker på at du vil slette dette?" + "value" : "Weet u zeker dat u dit wilt verwijderen?" } }, "pl" : { @@ -22175,16 +21916,16 @@ "value" : "説明" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beschrijving" + "value" : "Beskrivelse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Beskrivelse" + "value" : "Beschrijving" } }, "pl" : { @@ -22311,16 +22052,16 @@ "value" : "ダイレクトメッセージ" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Direct Bericht" + "value" : "Direkte melding" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Direkte melding" + "value" : "Direct Bericht" } }, "pl" : { @@ -22447,16 +22188,16 @@ "value" : "トレンド" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Populair" + "value" : "Populært" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Populært" + "value" : "Populair" } }, "pl" : { @@ -22619,13 +22360,13 @@ "value" : "트렌딩 해시태그" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Trending Hashtags" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Trending Hashtags" @@ -22809,16 +22550,16 @@ "value" : "발견하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ontdek" + "value" : "Oppdag" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Oppdag" + "value" : "Ontdek" } }, "pl" : { @@ -22999,16 +22740,16 @@ "value" : "사용자 추천" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gebruikers aanbevelen" + "value" : "Anbefal brukere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Anbefal brukere" + "value" : "Gebruikers aanbevelen" } }, "pl" : { @@ -23159,16 +22900,16 @@ "value" : "ダイレクトメッセージを書く" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Schrijf direct bericht" + "value" : "Skriv direkte melding" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Skriv direkte melding" + "value" : "Schrijf direct bericht" } }, "pl" : { @@ -23295,16 +23036,16 @@ "value" : "ダイレクトメッセージ" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Direct Bericht" + "value" : "Direkte melding" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Direkte melding" + "value" : "Direct Bericht" } }, "pl" : { @@ -23461,16 +23202,16 @@ "value" : "완료" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voltooid" + "value" : "Ferdig" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ferdig" + "value" : "Voltooid" } }, "pl" : { @@ -23651,16 +23392,16 @@ "value" : "완료" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voltooid" + "value" : "Ferdig" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ferdig" + "value" : "Voltooid" } }, "pl" : { @@ -23841,16 +23582,16 @@ "value" : "편집" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bewerken" + "value" : "Rediger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger" + "value" : "Bewerken" } }, "pl" : { @@ -23995,16 +23736,16 @@ "value" : "説明を編集" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beschrijving bewerken" + "value" : "Rediger beskrivelse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger beskrivelse" + "value" : "Beschrijving bewerken" } }, "pl" : { @@ -24161,16 +23902,16 @@ "value" : "목록 편집" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lijst bewerken" + "value" : "Rediger liste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger liste" + "value" : "Lijst bewerken" } }, "pl" : { @@ -24345,16 +24086,16 @@ "value" : "RSS 소스 편집" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Wijzig Rss Source" + "value" : "Rediger Rss kilde" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger Rss kilde" + "value" : "Wijzig Rss Source" } }, "pl" : { @@ -24535,16 +24276,16 @@ "value" : "목록에 추가/제거" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toevoegen/Verwijderen uit lijst" + "value" : "Legg til/fjern fra listen" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Legg til/fjern fra listen" + "value" : "Toevoegen/Verwijderen uit lijst" } }, "pl" : { @@ -24689,16 +24430,16 @@ "value" : "最近使用したもの" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Recent gebruikt" + "value" : "Nylig brukt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nylig brukt" + "value" : "Recent gebruikt" } }, "pl" : { @@ -24825,16 +24566,16 @@ "value" : "絵文字を検索" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Zoek naar Emoji" + "value" : "Søk etter Emoji" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Søk etter Emoji" + "value" : "Zoek naar Emoji" } }, "pl" : { @@ -24961,16 +24702,16 @@ "value" : "終わりに達しました" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Je bereikt het einde" + "value" : "Du når slutten" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Du når slutten" + "value" : "Je bereikt het einde" } }, "pl" : { @@ -25133,16 +24874,16 @@ "value" : "오류" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Foutmelding" + "value" : "Feil" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Feil" + "value" : "Foutmelding" } }, "pl" : { @@ -25323,16 +25064,16 @@ "value" : "오류" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Foutmelding" + "value" : "Feil" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Feil" + "value" : "Foutmelding" } }, "pl" : { @@ -25477,16 +25218,16 @@ "value" : "%@ のログインセッションが失効しました。" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "De aanmeldsessie is verlopen voor %@" + "value" : "Login økten er utløpt for %@" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Login økten er utløpt for %@" + "value" : "De aanmeldsessie is verlopen voor %@" } }, "pl" : { @@ -25601,16 +25342,16 @@ "value" : "再ログイン" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Re login" + "value" : "Kjør innlogging" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kjør innlogging" + "value" : "Re login" } }, "pl" : { @@ -25737,16 +25478,16 @@ "value" : "データのエクスポートに失敗しました" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gegevens exporteren mislukt" + "value" : "Kunne ikke eksportere data" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kunne ikke eksportere data" + "value" : "Gegevens exporteren mislukt" } }, "pl" : { @@ -25801,131 +25542,11 @@ }, "Failed to load models" : { "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "فشل في تحميل النماذج" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nepodařilo se načíst modely" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kunne ikke indlæse modeller" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fehler beim Laden der Modelle" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Αποτυχία φόρτωσης μοντέλων" - } - }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Failed to load models" } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Error al cargar los modelos" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mallien lataaminen epäonnistui" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Impossible de charger les modèles" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Impossibile caricare i modelli" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "モデルの読み込みに失敗しました" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "modellen laden mislukt" - } - }, - "no" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kan ikke laste inn modeller" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nie udało się załadować modeli" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Falha ao carregar modelos" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Falha ao carregar modelos" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Încărcarea modelelor a eșuat" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Не удалось загрузить модели" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Det gick inte att ladda modeller" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Не вдалося завантажити моделі" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "加载模型失败" - } } } }, @@ -26003,16 +25624,16 @@ "value" : "fx_share" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Fx_share" + "value" : "Del via FxEmbed" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Del via FxEmbed" + "value" : "Fx_share" } }, "pl" : { @@ -26169,16 +25790,16 @@ "value" : "북마크" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bladwijzers" + "value" : "Bokmerker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Bokmerker" + "value" : "Bladwijzers" } }, "pl" : { @@ -26359,16 +25980,16 @@ "value" : "발견하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ontdek" + "value" : "Oppdag" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Oppdag" + "value" : "Ontdek" } }, "pl" : { @@ -26513,16 +26134,16 @@ "value" : "お気に入り" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Favoriete" + "value" : "Favoritt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Favoritt" + "value" : "Favoriete" } }, "pl" : { @@ -26685,16 +26306,16 @@ "value" : "추천" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Aanbevolen" + "value" : "Anbefalt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Anbefalt" + "value" : "Aanbevolen" } }, "pl" : { @@ -26875,16 +26496,16 @@ "value" : "피드" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Feeds" + "value" : "Strøm" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Strøm" + "value" : "Feeds" } }, "pl" : { @@ -27065,16 +26686,16 @@ "value" : "홈" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Startpagina" + "value" : "Hjem" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hjem" + "value" : "Startpagina" } }, "pl" : { @@ -27255,16 +26876,16 @@ "value" : "목록" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Klantenlijst" + "value" : "Liste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liste" + "value" : "Klantenlijst" } }, "pl" : { @@ -27445,16 +27066,16 @@ "value" : "내 정보" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "IK" + "value" : "Meg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Meg" + "value" : "IK" } }, "pl" : { @@ -27599,16 +27220,16 @@ "value" : "Notification" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Notificatie" + "value" : "Varsling" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Varsling" + "value" : "Notificatie" } }, "pl" : { @@ -27735,16 +27356,16 @@ "value" : "インポート完了" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Importeren voltooid" + "value" : "Import fullført" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Import fullført" + "value" : "Importeren voltooid" } }, "pl" : { @@ -27865,16 +27486,16 @@ "value" : "これはファイルからデータをインポートします。一致するIDを持つ既存のレコードは置き換えられます。続行しますか?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Dit zal gegevens uit het bestand importeren. Bestaande records met bijpassende IDs zullen worden vervangen. Wilt u doorgaan?" + "value" : "Dette vil importere data fra filen. Eksisterende poster med samsvarende ID-er vil bli erstattet. Vil du fortsette?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Dette vil importere data fra filen. Eksisterende poster med samsvarende ID-er vil bli erstattet. Vil du fortsette?" + "value" : "Dit zal gegevens uit het bestand importeren. Bestaande records met bijpassende IDs zullen worden vervangen. Wilt u doorgaan?" } }, "pl" : { @@ -27995,16 +27616,16 @@ "value" : "インポートの確認" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Import bevestigen" + "value" : "Bekreft import" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Bekreft import" + "value" : "Import bevestigen" } }, "pl" : { @@ -28125,16 +27746,16 @@ "value" : "データのインポートに失敗しました" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gegevens importeren mislukt" + "value" : "Kan ikke importere data" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kan ikke importere data" + "value" : "Gegevens importeren mislukt" } }, "pl" : { @@ -28291,16 +27912,16 @@ "value" : "밝은 색" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Licht" + "value" : "Lys" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lys" + "value" : "Licht" } }, "pl" : { @@ -28481,16 +28102,16 @@ "value" : "좋아요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "vind-ik-leuk" + "value" : "Lik" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lik" + "value" : "vind-ik-leuk" } }, "pl" : { @@ -28635,16 +28256,16 @@ "value" : "いいね!" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Leukgevonden" + "value" : "Likte" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Likte" + "value" : "Leukgevonden" } }, "pl" : { @@ -28801,16 +28422,16 @@ "value" : "목록 설명" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Omschrijving lijst" + "value" : "Liste beskrivelse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liste beskrivelse" + "value" : "Omschrijving lijst" } }, "pl" : { @@ -28955,16 +28576,16 @@ "value" : "リストアイコン" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lijst pictogram" + "value" : "Liste ikon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liste ikon" + "value" : "Lijst pictogram" } }, "pl" : { @@ -29121,16 +28742,16 @@ "value" : "목록 설명" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Omschrijving lijst" + "value" : "Liste beskrivelse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liste beskrivelse" + "value" : "Omschrijving lijst" } }, "pl" : { @@ -29311,16 +28932,16 @@ "value" : "목록 구성원 편집" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lijstleden bewerken" + "value" : "Rediger listemedlemmer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger listemedlemmer" + "value" : "Lijstleden bewerken" } }, "pl" : { @@ -29501,16 +29122,16 @@ "value" : "목록 이름" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lijst naam" + "value" : "Navn på liste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Navn på liste" + "value" : "Lijst naam" } }, "pl" : { @@ -29691,16 +29312,16 @@ "value" : "목록 편집" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lijst bewerken" + "value" : "Rediger liste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger liste" + "value" : "Lijst bewerken" } }, "pl" : { @@ -29845,16 +29466,16 @@ "value" : "ここには何もありません" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Niets hier" + "value" : "Ingenting her" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ingenting her" + "value" : "Niets hier" } }, "pl" : { @@ -30011,16 +29632,16 @@ "value" : "구성원" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "leden" + "value" : "Medlemmer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Medlemmer" + "value" : "leden" } }, "pl" : { @@ -30201,16 +29822,16 @@ "value" : "목록 이름" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lijst naam" + "value" : "Navn på liste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Navn på liste" + "value" : "Lijst naam" } }, "pl" : { @@ -30355,16 +29976,16 @@ "value" : "リストアイコン" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lijst pictogram" + "value" : "Liste ikon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liste ikon" + "value" : "Lijst pictogram" } }, "pl" : { @@ -30521,16 +30142,16 @@ "value" : "목록 설명" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Omschrijving lijst" + "value" : "Liste beskrivelse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liste beskrivelse" + "value" : "Omschrijving lijst" } }, "pl" : { @@ -30711,16 +30332,16 @@ "value" : "목록 설명" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Omschrijving lijst" + "value" : "Liste beskrivelse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liste beskrivelse" + "value" : "Omschrijving lijst" } }, "pl" : { @@ -30901,16 +30522,16 @@ "value" : "목록 이름" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lijst naam" + "value" : "Navn på liste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Navn på liste" + "value" : "Lijst naam" } }, "pl" : { @@ -31091,16 +30712,16 @@ "value" : "목록 이름" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lijst naam" + "value" : "Navn på liste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Navn på liste" + "value" : "Lijst naam" } }, "pl" : { @@ -31281,16 +30902,16 @@ "value" : "목록 생성" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lijst aanmaken" + "value" : "Lag oppgaveliste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lag oppgaveliste" + "value" : "Lijst aanmaken" } }, "pl" : { @@ -31369,131 +30990,11 @@ }, "Loading models..." : { "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تحميل النماذج..." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Načítání modelů..." - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Indlæser modeller..." - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lade Modelle..." - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Φόρτωση μοντέλων..." - } - }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Loading models..." } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cargando modelos..." - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ladataan malleja..." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chargement des modèles..." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Caricamento modelli..." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "モデルを読み込み中..." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Motoren laden..." - } - }, - "no" : { - "stringUnit" : { - "state" : "translated", - "value" : "Laster modeller..." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ładowanie modeli..." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Carregando modelos..." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Carregando modelos..." - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Se încarcă modelele..." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Загрузка моделей..." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Laddar modeller..." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Завантаження моделей..." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "正在加载模型..." - } } } }, @@ -31565,16 +31066,16 @@ "value" : "読み込み中..." } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Laden..." + "value" : "Laster..." } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Laster..." + "value" : "Laden..." } }, "pl" : { @@ -31737,16 +31238,16 @@ "value" : "삭제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijderen" + "value" : "Slett" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett" + "value" : "Verwijderen" } }, "pl" : { @@ -31927,16 +31428,16 @@ "value" : "타임라인을 위한 로컬 필터 설정" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lokale filterinstellingen voor tijdlijn" + "value" : "Lokale filterinnstillinger for tidslinjen" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lokale filterinnstillinger for tidslinjen" + "value" : "Lokale filterinstellingen voor tijdlijn" } }, "pl" : { @@ -32117,16 +31618,16 @@ "value" : "편집" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bewerken" + "value" : "Rediger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger" + "value" : "Bewerken" } }, "pl" : { @@ -32277,16 +31778,16 @@ "value" : "フィルターを編集" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Filter bewerken" + "value" : "Rediger filter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger filter" + "value" : "Filter bewerken" } }, "pl" : { @@ -32443,13 +31944,13 @@ "value" : "키워드" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Keyword" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Keyword" @@ -32597,16 +32098,16 @@ "value" : "キーワードを入力してください" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voer een trefwoord in" + "value" : "Skriv inn et nøkkelord" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Skriv inn et nøkkelord" + "value" : "Voer een trefwoord in" } }, "pl" : { @@ -32733,16 +32234,16 @@ "value" : "通知を有効にする" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Inschakelen in melding" + "value" : "Aktiver i varsel" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aktiver i varsel" + "value" : "Inschakelen in melding" } }, "pl" : { @@ -32863,16 +32364,16 @@ "value" : "フィルタを有効にする" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Filter in" + "value" : "Aktiver filter i" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aktiver filter i" + "value" : "Filter in" } }, "pl" : { @@ -32993,16 +32494,16 @@ "value" : "検索で有効にする" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Inschakelen in zoekopdracht" + "value" : "Aktiver i søk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aktiver i søk" + "value" : "Inschakelen in zoekopdracht" } }, "pl" : { @@ -33123,16 +32624,16 @@ "value" : "タイムラインで有効にする" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Inschakelen op tijdlijn" + "value" : "Aktiver i tidslinjen" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aktiver i tidslinjen" + "value" : "Inschakelen op tijdlijn" } }, "pl" : { @@ -33289,16 +32790,16 @@ "value" : "로컬 필터" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lokaal filter" + "value" : "Lokalt filter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lokalt filter" + "value" : "Lokaal filter" } }, "pl" : { @@ -33479,16 +32980,16 @@ "value" : "브라우징 기록 보기 또는 검색" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bekijk of zoek uw browsegeschiedenis" + "value" : "Vis eller søk i historikk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis eller søk i historikk" + "value" : "Bekijk of zoek uw browsegeschiedenis" } }, "pl" : { @@ -33621,16 +33122,16 @@ "value" : "Cerca nella cache…" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Zoeken in cache…" + "value" : "Søk i mellomlager…" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Søk i mellomlager…" + "value" : "Zoeken in cache…" } }, "pl" : { @@ -33787,16 +33288,16 @@ "value" : "게시물" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Statuses" + "value" : "Innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg" + "value" : "Statuses" } }, "pl" : { @@ -33977,16 +33478,16 @@ "value" : "로컬 기록" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lokale geschiedenis" + "value" : "Lokal historikk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lokal historikk" + "value" : "Lokale geschiedenis" } }, "pl" : { @@ -34167,16 +33668,16 @@ "value" : "사용자" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gebruikers" + "value" : "Brukere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Brukere" + "value" : "Gebruikers" } }, "pl" : { @@ -34357,16 +33858,16 @@ "value" : "로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Aanmelden" + "value" : "Innlogging" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlogging" + "value" : "Aanmelden" } }, "pl" : { @@ -34547,16 +34048,16 @@ "value" : "로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Aanmelden" + "value" : "Innlogging" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlogging" + "value" : "Aanmelden" } }, "pl" : { @@ -34701,16 +34202,16 @@ "value" : "ログアウト" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Afmelden" + "value" : "Logg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg" + "value" : "Afmelden" } }, "pl" : { @@ -34873,16 +34374,16 @@ "value" : "고정된 투트" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vastgepinde toot" + "value" : "Festet innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Festet innlegg" + "value" : "Vastgepinde toot" } }, "pl" : { @@ -35063,16 +34564,16 @@ "value" : "덜 보기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Minder weergeven" + "value" : "Vis mindre" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis mindre" + "value" : "Minder weergeven" } }, "pl" : { @@ -35253,16 +34754,16 @@ "value" : "더 보기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toon meer" + "value" : "Vis mer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis mer" + "value" : "Toon meer" } }, "pl" : { @@ -35443,16 +34944,16 @@ "value" : "좋아요를 눌렀습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "favoriet" + "value" : "favorisert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "favorisert" + "value" : "favoriet" } }, "pl" : { @@ -35633,16 +35134,16 @@ "value" : "당신을 팔로우했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "volgt jou nu" + "value" : "fulgte deg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "fulgte deg" + "value" : "volgt jou nu" } }, "pl" : { @@ -35817,16 +35318,16 @@ "value" : "당신을 팔로우 요청했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "verzoek je te volgen" + "value" : "forespørsel om å følge deg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "forespørsel om å følge deg" + "value" : "verzoek je te volgen" } }, "pl" : { @@ -36007,16 +35508,16 @@ "value" : "당신을 언급했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "noemde je" + "value" : "nevnte deg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "nevnte deg" + "value" : "noemde je" } }, "pl" : { @@ -36191,16 +35692,16 @@ "value" : "참여했던 설문조사가 종료되었습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Een poll waaraan u deelnam, is beëindigd" + "value" : "En avstemming du deltok i er avsluttet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "En avstemming du deltok i er avsluttet" + "value" : "Een poll waaraan u deelnam, is beëindigd" } }, "pl" : { @@ -36381,16 +35882,16 @@ "value" : "리블로그했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "gedeeld" + "value" : "reblogget" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "reblogget" + "value" : "gedeeld" } }, "pl" : { @@ -36565,16 +36066,16 @@ "value" : "투트를 부스트했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "toot heeft geboost" + "value" : "repostet et innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "repostet et innlegg" + "value" : "toot heeft geboost" } }, "pl" : { @@ -36749,16 +36250,16 @@ "value" : "투트를 업데이트했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "heeft een toot bijgewerkt" + "value" : "oppdaterte et innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "oppdaterte et innlegg" + "value" : "heeft een toot bijgewerkt" } }, "pl" : { @@ -36933,16 +36434,16 @@ "value" : "정말로 이것을 신고하시겠습니까?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Weet u zeker dat u dit wilt melden?" + "value" : "Er du sikker på at du vil rapportere dette?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Er du sikker på at du vil rapportere dette?" + "value" : "Weet u zeker dat u dit wilt melden?" } }, "pl" : { @@ -37123,16 +36624,16 @@ "value" : "신고하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporteren" + "value" : "Rapporter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporter" + "value" : "Rapporteren" } }, "pl" : { @@ -37283,16 +36784,16 @@ "value" : "ローカルタイムライン" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Lokale tijdlijn" + "value" : "Lokal tidslinje" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lokal tidslinje" + "value" : "Lokale tijdlijn" } }, "pl" : { @@ -37425,16 +36926,16 @@ "value" : "公開タイムライン" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Publieke tijdlijn" + "value" : "Offentlig tidslinje" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Offentlig tidslinje" + "value" : "Publieke tijdlijn" } }, "pl" : { @@ -37591,16 +37092,16 @@ "value" : "팔로워" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgers" + "value" : "Følgere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følgere" + "value" : "Volgers" } }, "pl" : { @@ -37781,16 +37282,16 @@ "value" : "팔로잉" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgt" + "value" : "Følger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følger" + "value" : "Volgt" } }, "pl" : { @@ -37966,16 +37467,16 @@ "value" : "브레인 다이버의 링크를 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Plaats de link naar Brain Diver" + "value" : "Post linken til Brain Diver" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post linken til Brain Diver" + "value" : "Plaats de link naar Brain Diver" } }, "pl" : { @@ -38151,13 +37652,13 @@ "value" : "Misskey-Misskey 라투마" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Misskey-Misskey La-Tu-Ma" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Misskey-Misskey La-Tu-Ma" @@ -38336,16 +37837,16 @@ "value" : "브레인 다이버" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Hersenen Duiver" + "value" : "Hjerne sover" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hjerne sover" + "value" : "Hersenen Duiver" } }, "pl" : { @@ -38521,16 +38022,16 @@ "value" : "버블 게임에서 동시에 가장 큰 두 객체" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Twee van de grootste objecten in het bubbelspel op hetzelfde moment" + "value" : "To av de største objektene i boblespillet samtidig" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "To av de største objektene i boblespillet samtidig" + "value" : "Twee van de grootste objecten in het bubbelspel op hetzelfde moment" } }, "pl" : { @@ -38706,16 +38207,16 @@ "value" : "이렇게 점심 도시락을 채울 수 있습니다 🤯 🤯 약간." } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Je kunt een lunchdoos zoals deze 🤯 🤯 een beetje opvullen." + "value" : "Du kan fylle en lunsj boks som dette 🤯 🤯 en bit." } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Du kan fylle en lunsj boks som dette 🤯 🤯 en bit." + "value" : "Je kunt een lunchdoos zoals deze 🤯 🤯 een beetje opvullen." } }, "pl" : { @@ -38891,16 +38392,16 @@ "value" : "더블 🤯" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Dubbel:exploderen_head:" + "value" : "Dobbel🤯" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Dobbel🤯" + "value" : "Dubbel:exploderen_head:" } }, "pl" : { @@ -39076,16 +38577,16 @@ "value" : "버블 게임에서 가장 큰 객체" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Het grootste object in het bubbelspel" + "value" : "Det største objektet i boblespill" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Det største objektet i boblespill" + "value" : "Het grootste object in het bubbelspel" } }, "pl" : { @@ -39261,13 +38762,13 @@ "value" : "🤯" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "🤯" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "🤯" @@ -39446,16 +38947,16 @@ "value" : "여기를 클릭했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Je hebt hier geklikt" + "value" : "Du har klikket her" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Du har klikket her" + "value" : "Je hebt hier geklikt" } }, "pl" : { @@ -39637,16 +39138,16 @@ "value" : "여기를 클릭하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Klik hier" + "value" : "Klikk her" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Klikk her" + "value" : "Klik hier" } }, "pl" : { @@ -39822,16 +39323,16 @@ "value" : "Misskey를 최소 30분 동안 열어 두세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Houd Misskey open voor ten minste 30 minuten" + "value" : "Behold Misskey åpnet i minst 30 minutter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Behold Misskey åpnet i minst 30 minutter" + "value" : "Houd Misskey open voor ten minste 30 minuten" } }, "pl" : { @@ -40013,16 +39514,16 @@ "value" : "짧은 휴식" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Korte pauze" + "value" : "Kort pause" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kort pause" + "value" : "Korte pauze" } }, "pl" : { @@ -40204,16 +39705,16 @@ "value" : "Misskey를 최소 60분 동안 열어 두세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Houd Misskey open voor ten minste 60 minuten" + "value" : "Behold Misskey åpnet i minst 60 minutter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Behold Misskey åpnet i minst 60 minutter" + "value" : "Houd Misskey open voor ten minste 60 minuten" } }, "pl" : { @@ -40395,16 +39896,16 @@ "value" : "Misskey에서는 \"Miss\"가 없습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Geen \"Miss\" in Misskey" + "value" : "Ingen \"Misske\" i Misskey" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ingen \"Misske\" i Misskey" + "value" : "Geen \"Miss\" in Misskey" } }, "pl" : { @@ -40580,16 +40081,16 @@ "value" : "30개의 업적 획득" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verdien 30 prestaties" + "value" : "Tjen 30 prestasjoner" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tjen 30 prestasjoner" + "value" : "Verdien 30 prestaties" } }, "pl" : { @@ -40765,16 +40266,16 @@ "value" : "업적 수집가" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Prestatie Verzamelaar" + "value" : "Prestasjon samler" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Prestasjon samler" + "value" : "Prestatie Verzamelaar" } }, "pl" : { @@ -40950,16 +40451,16 @@ "value" : "쿠키를 클릭했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Klikte de cookie" + "value" : "Klikket på infokapselen" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Klikket på infokapselen" + "value" : "Klikte de cookie" } }, "pl" : { @@ -41135,16 +40636,16 @@ "value" : "기다려, 정확한 웹사이트에 있나요?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Wacht, ben je op de juiste website?" + "value" : "Vent, er du på riktig nettside?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vent, er du på riktig nettside?" + "value" : "Wacht, ben je op de juiste website?" } }, "pl" : { @@ -41320,16 +40821,16 @@ "value" : "쿠키를 클릭하는 게임" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Een spel waarin je op cookies klikt" + "value" : "Et spill hvor du klikker på informasjonskapsler" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Et spill hvor du klikker på informasjonskapsler" + "value" : "Een spel waarin je op cookies klikt" } }, "pl" : { @@ -41505,16 +41006,16 @@ "value" : "드라이브에서 재귀적으로 중첩된 폴더 만들기 시도" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Poging een resource map te maken in de Drive" + "value" : "Forsøk på å opprette en rekursivt nestet mappe i stasjon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Forsøk på å opprette en rekursivt nestet mappe i stasjon" + "value" : "Poging een resource map te maken in de Drive" } }, "pl" : { @@ -41690,16 +41191,16 @@ "value" : "순환 참조" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ronde referentie" + "value" : "Sirkulær referanse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sirkulær referanse" + "value" : "Ronde referentie" } }, "pl" : { @@ -41881,16 +41382,16 @@ "value" : "1명의 팔로워 얻기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Krijg 1 volger" + "value" : "Få 1 følger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Få 1 følger" + "value" : "Krijg 1 volger" } }, "pl" : { @@ -42072,16 +41573,16 @@ "value" : "첫 번째 팔로워" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Eerste volger" + "value" : "Første tilhenger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Første tilhenger" + "value" : "Eerste volger" } }, "pl" : { @@ -42263,16 +41764,16 @@ "value" : "10명의 팔로워 얻기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Krijg 10 volgers" + "value" : "Få 10 følgere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Få 10 følgere" + "value" : "Krijg 10 volgers" } }, "pl" : { @@ -42454,16 +41955,16 @@ "value" : "나를 팔로우하세요!" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volg mij!" + "value" : "Følg meg!" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følg meg!" + "value" : "Volg mij!" } }, "pl" : { @@ -42645,16 +42146,16 @@ "value" : "50명의 팔로워 얻기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Krijg 50 volgers" + "value" : "Få 50 følgere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Få 50 følgere" + "value" : "Krijg 50 volgers" } }, "pl" : { @@ -42830,16 +42331,16 @@ "value" : "사람들이 몰려옵니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Komt in massa's" + "value" : "Hva skjer med flere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hva skjer med flere" + "value" : "Komt in massa's" } }, "pl" : { @@ -43021,16 +42522,16 @@ "value" : "100명의 팔로워 얻기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Krijg 100 volgers" + "value" : "Få 100 følgere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Få 100 følgere" + "value" : "Krijg 100 volgers" } }, "pl" : { @@ -43212,16 +42713,16 @@ "value" : "인기 있는" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Populair" + "value" : "Populær" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Populær" + "value" : "Populair" } }, "pl" : { @@ -43403,16 +42904,16 @@ "value" : "300명의 팔로워 얻기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Krijg 300 volgers" + "value" : "Få 300 følgere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Få 300 følgere" + "value" : "Krijg 300 volgers" } }, "pl" : { @@ -43588,16 +43089,16 @@ "value" : "한 줄로 서주세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gelieve een enkele regel te vormen" + "value" : "Fyll inn en enkelt linje" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Fyll inn en enkelt linje" + "value" : "Gelieve een enkele regel te vormen" } }, "pl" : { @@ -43779,16 +43280,16 @@ "value" : "500명의 팔로워 얻기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Krijg 500 volgers" + "value" : "Få 500 følgere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Få 500 følgere" + "value" : "Krijg 500 volgers" } }, "pl" : { @@ -43970,16 +43471,16 @@ "value" : "라디오 타워" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Radio toren" + "value" : "Radio tårn" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Radio tårn" + "value" : "Radio toren" } }, "pl" : { @@ -44161,16 +43662,16 @@ "value" : "1,000명의 팔로워 얻기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Krijg 1000 volgers" + "value" : "Få 1000 følgere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Få 1000 følgere" + "value" : "Krijg 1000 volgers" } }, "pl" : { @@ -44352,16 +43853,16 @@ "value" : "인플루언서" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bevochtiger" + "value" : "Påvirkning" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Påvirkning" + "value" : "Bevochtiger" } }, "pl" : { @@ -44543,16 +44044,16 @@ "value" : "사용자를 팔로우하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volg een gebruiker" + "value" : "Følg en bruker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følg en bruker" + "value" : "Volg een gebruiker" } }, "pl" : { @@ -44728,16 +44229,16 @@ "value" : "첫 번째 사용자를 팔로우하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Je volgt je eerste gebruiker" + "value" : "Følger din første bruker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følger din første bruker" + "value" : "Je volgt je eerste gebruiker" } }, "pl" : { @@ -44919,16 +44420,16 @@ "value" : "10명 사용자 팔로우하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volg 10 gebruikers" + "value" : "Følg 10 brukere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følg 10 brukere" + "value" : "Volg 10 gebruikers" } }, "pl" : { @@ -45104,16 +44605,16 @@ "value" : "계속... 계속..." } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Hou door... ga zo door..." + "value" : "Fortsett med... fortsett å være med..." } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Fortsett med... fortsett å være med..." + "value" : "Hou door... ga zo door..." } }, "pl" : { @@ -45295,16 +44796,16 @@ "value" : "50명 계정 팔로우하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volg 50 accounts" + "value" : "Følg 50 kontoer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følg 50 kontoer" + "value" : "Volg 50 accounts" } }, "pl" : { @@ -45486,16 +44987,16 @@ "value" : "많은 친구들" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Veel vrienden" + "value" : "Masse av venner" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Masse av venner" + "value" : "Veel vrienden" } }, "pl" : { @@ -45677,16 +45178,16 @@ "value" : "100명 계정 팔로우하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volg 100 accounts" + "value" : "Følg 100 kontoer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følg 100 kontoer" + "value" : "Volg 100 accounts" } }, "pl" : { @@ -45868,16 +45369,16 @@ "value" : "100명의 친구들" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "100 vrienden" + "value" : "100 venner" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "100 venner" + "value" : "100 vrienden" } }, "pl" : { @@ -46059,16 +45560,16 @@ "value" : "300명 계정 팔로우하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volg 300 accounts" + "value" : "Følg 300 kontoer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følg 300 kontoer" + "value" : "Volg 300 accounts" } }, "pl" : { @@ -46244,16 +45745,16 @@ "value" : "친구 과잉" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vriend overbelast" + "value" : "Venn overbelastet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Venn overbelastet" + "value" : "Vriend overbelast" } }, "pl" : { @@ -46429,16 +45930,16 @@ "value" : "숨겨진 보물을 찾았습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Je hebt de verborgen schat gevonden" + "value" : "Du har funnet den skjulte skatten" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Du har funnet den skjulte skatten" + "value" : "Je hebt de verborgen schat gevonden" } }, "pl" : { @@ -46614,16 +46115,16 @@ "value" : "보물 찾기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Schat Jacht" + "value" : "Skatt Jakt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Skatt Jakt" + "value" : "Schat Jacht" } }, "pl" : { @@ -46799,16 +46300,16 @@ "value" : "귀하의 홈 타임라인 속도가 분당 20개 노트를 초과하도록 하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Heb de snelheid van je home tijdlijn groter dan 20 npm (notities per minuut)" + "value" : "Få hastigheten på hjemmetidslinjen over 20 npm (notater per minutt)" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Få hastigheten på hjemmetidslinjen over 20 npm (notater per minutt)" + "value" : "Heb de snelheid van je home tijdlijn groter dan 20 npm (notities per minuut)" } }, "pl" : { @@ -46984,16 +46485,16 @@ "value" : "흐르는 타임라인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vloeiende tijdlijn" + "value" : "flytende tidslinje" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "flytende tidslinje" + "value" : "Vloeiende tijdlijn" } }, "pl" : { @@ -47169,16 +46670,16 @@ "value" : "\"I ❤ #Misskey\" 게시하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Plaats \"I ❤️ #Misskey\"" + "value" : "Innlegg \"I ❤️ #Misskey\"" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg \"I ❤️ #Misskey\"" + "value" : "Plaats \"I ❤️ #Misskey\"" } }, "pl" : { @@ -47354,16 +46855,16 @@ "value" : "미스키의 개발 팀이 당신의 지원에 매우 감사드립니다!" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Misskey's ontwikkelingsteam waardeert je steun zeer!" + "value" : "Misskey's utviklingsteam setter stor pris på din støtte!" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Misskey's utviklingsteam setter stor pris på din støtte!" + "value" : "Misskey's ontwikkelingsteam waardeert je steun zeer!" } }, "pl" : { @@ -47539,16 +47040,16 @@ "value" : "나는 미스키를 사랑해요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ik hou van Misskey" + "value" : "Jeg elsker Misskey" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Jeg elsker Misskey" + "value" : "Ik hou van Misskey" } }, "pl" : { @@ -47694,16 +47195,16 @@ "value" : "10秒ごとに0.005%の確率で獲得できます" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Heeft een kans om te worden verkregen met een kans van 0,005% per 10 seconden" + "value" : "Har en sjanse til å bli oppnådd med en sannsynlighet på 0,005% hvert 10 sekund" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Har en sjanse til å bli oppnådd med en sannsynlighet på 0,005% hvert 10 sekund" + "value" : "Heeft een kans om te worden verkregen met een kans van 0,005% per 10 seconden" } }, "pl" : { @@ -47861,16 +47362,16 @@ "value" : "그냥 운이 좋았다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Eenvoudig Geluk" + "value" : "Bare ren lykke" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Bare ren lykke" + "value" : "Eenvoudig Geluk" } }, "pl" : { @@ -48052,16 +47553,16 @@ "value" : "생일에 로그인하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in op je verjaardag" + "value" : "Logg inn på bursdagen din" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn på bursdagen din" + "value" : "Log in op je verjaardag" } }, "pl" : { @@ -48243,16 +47744,16 @@ "value" : "생일 축하합니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gefeliciteerd met je verjaardag" + "value" : "Gratulerer med dagen" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Gratulerer med dagen" + "value" : "Gefeliciteerd met je verjaardag" } }, "pl" : { @@ -48434,16 +47935,16 @@ "value" : "새해 첫날에 로그인했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ingelogd op de eerste dag van het jaar" + "value" : "Logget på den første dagen av året" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logget på den første dagen av året" + "value" : "Ingelogd op de eerste dag van het jaar" } }, "pl" : { @@ -48619,16 +48120,16 @@ "value" : "이 인스턴스에서 또 다른 훌륭한 해를 기원합니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Tot nog een geweldig jaar op dit exemplaar" + "value" : "Til et annet stort år i dette tilfellet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Til et annet stort år i dette tilfellet" + "value" : "Tot nog een geweldig jaar op dit exemplaar" } }, "pl" : { @@ -48810,16 +48311,16 @@ "value" : "새해 복 많이 받으세요!" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gelukkig nieuwjaar!" + "value" : "Godt nytt år!" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Godt nytt år!" + "value" : "Gelukkig nieuwjaar!" } }, "pl" : { @@ -48995,16 +48496,16 @@ "value" : "총 3일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "In totaal 3 dagen inloggen" + "value" : "Logg inn totalt 3 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn totalt 3 dager" + "value" : "In totaal 3 dagen inloggen" } }, "pl" : { @@ -49180,16 +48681,16 @@ "value" : "오늘부터 저를 미스키스트라고 부르세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Begint vandaag, noem me Misskist" + "value" : "Starter i dag, bare ring meg Misskist" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Starter i dag, bare ring meg Misskist" + "value" : "Begint vandaag, noem me Misskist" } }, "pl" : { @@ -49371,16 +48872,16 @@ "value" : "초보자 I" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beginner I" + "value" : "Nybegynner jeg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nybegynner jeg" + "value" : "Beginner I" } }, "pl" : { @@ -49556,16 +49057,16 @@ "value" : "총 7일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in voor een totaal van 7 dagen" + "value" : "Logg inn totalt 7 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn totalt 7 dager" + "value" : "Log in voor een totaal van 7 dagen" } }, "pl" : { @@ -49741,16 +49242,16 @@ "value" : "뭔가 익숙해지셨나요?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Heb je het gevoel dat je dingen al opgehangen hebt?" + "value" : "Føler som du har fått heftet på ting enda?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Føler som du har fått heftet på ting enda?" + "value" : "Heb je het gevoel dat je dingen al opgehangen hebt?" } }, "pl" : { @@ -49932,16 +49433,16 @@ "value" : "초보자 II" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beginner II" + "value" : "Nybegynner II" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nybegynner II" + "value" : "Beginner II" } }, "pl" : { @@ -50117,16 +49618,16 @@ "value" : "총 15일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in voor een totaal van 15 dagen" + "value" : "Logg inn totalt 15 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn totalt 15 dager" + "value" : "Log in voor een totaal van 15 dagen" } }, "pl" : { @@ -50308,16 +49809,16 @@ "value" : "초보자 III" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beginner III" + "value" : "Nybegynner III" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nybegynner III" + "value" : "Beginner III" } }, "pl" : { @@ -50493,16 +49994,16 @@ "value" : "총 30일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in voor een totaal van 30 dagen" + "value" : "Logg inn totalt 30 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn totalt 30 dager" + "value" : "Log in voor een totaal van 30 dagen" } }, "pl" : { @@ -50678,13 +50179,13 @@ "value" : "미스키스트 I" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Misskist I" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Misskist I" @@ -50863,16 +50364,16 @@ "value" : "총 60일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in voor een totaal van 60 dagen" + "value" : "Logg inn totalt 60 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn totalt 60 dager" + "value" : "Log in voor een totaal van 60 dagen" } }, "pl" : { @@ -51048,13 +50549,13 @@ "value" : "미스키스트 II" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Misskist II" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Misskist II" @@ -51233,16 +50734,16 @@ "value" : "총 100일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in voor een totaal van 100 dagen" + "value" : "Logg inn i totalt 100 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn i totalt 100 dager" + "value" : "Log in voor een totaal van 100 dagen" } }, "pl" : { @@ -51418,16 +50919,16 @@ "value" : "폭력적인 미스키스트" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gewelddadige Misskist" + "value" : "Voldelig delegasjon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Voldelig delegasjon" + "value" : "Gewelddadige Misskist" } }, "pl" : { @@ -51603,13 +51104,13 @@ "value" : "미스키스트 III" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Misskist III" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Misskist III" @@ -51788,16 +51289,16 @@ "value" : "총 200일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in voor een totaal van 200 dagen" + "value" : "Logg inn for totalt 200 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn for totalt 200 dager" + "value" : "Log in voor een totaal van 200 dagen" } }, "pl" : { @@ -51973,16 +51474,16 @@ "value" : "정상 I" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Normaal I" + "value" : "Vanlig 1" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vanlig 1" + "value" : "Normaal I" } }, "pl" : { @@ -52158,16 +51659,16 @@ "value" : "총 300일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in voor een totaal van 300 dagen" + "value" : "Logg inn totalt 300 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn totalt 300 dager" + "value" : "Log in voor een totaal van 300 dagen" } }, "pl" : { @@ -52343,16 +51844,16 @@ "value" : "정상 II" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Normaal II" + "value" : "Vanlig II" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vanlig II" + "value" : "Normaal II" } }, "pl" : { @@ -52528,16 +52029,16 @@ "value" : "총 400일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in totaal 400 dagen in" + "value" : "Logg inn i totalt 400 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn i totalt 400 dager" + "value" : "Log in totaal 400 dagen in" } }, "pl" : { @@ -52713,16 +52214,16 @@ "value" : "정상 III" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Normaal III" + "value" : "Vanlig III" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vanlig III" + "value" : "Normaal III" } }, "pl" : { @@ -52898,16 +52399,16 @@ "value" : "총 500일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in voor een totaal van 500 dagen" + "value" : "Logg inn totalt 500 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn totalt 500 dager" + "value" : "Log in voor een totaal van 500 dagen" } }, "pl" : { @@ -53083,16 +52584,16 @@ "value" : "내 친구들, 내가 노트를 좋아한다고 자주 말해왔습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beste vrienden, er is vaak gezegd dat ik van notities houd" + "value" : "Mine venner har ofte blitt sagt at jeg liker notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Mine venner har ofte blitt sagt at jeg liker notater" + "value" : "Beste vrienden, er is vaak gezegd dat ik van notities houd" } }, "pl" : { @@ -53268,16 +52769,16 @@ "value" : "전문가 I" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Expert I" + "value" : "Ekspert 1" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ekspert 1" + "value" : "Expert I" } }, "pl" : { @@ -53453,16 +52954,16 @@ "value" : "총 600일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in voor een totaal van 600 dagen" + "value" : "Logg inn i totalt 600 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn i totalt 600 dager" + "value" : "Log in voor een totaal van 600 dagen" } }, "pl" : { @@ -53638,16 +53139,16 @@ "value" : "전문가 II" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Expert II" + "value" : "Ekspert II" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ekspert II" + "value" : "Expert II" } }, "pl" : { @@ -53823,16 +53324,16 @@ "value" : "총 700일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in totaal 700 dagen in" + "value" : "Logg inn i til sammen 700 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn i til sammen 700 dager" + "value" : "Log in totaal 700 dagen in" } }, "pl" : { @@ -54008,16 +53509,16 @@ "value" : "전문가 III" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Expert III" + "value" : "Ekspert III" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ekspert III" + "value" : "Expert III" } }, "pl" : { @@ -54193,16 +53694,16 @@ "value" : "총 800일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in voor een totaal van 800 dagen" + "value" : "Log inn totalt 800 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Log inn totalt 800 dager" + "value" : "Log in voor een totaal van 800 dagen" } }, "pl" : { @@ -54378,16 +53879,16 @@ "value" : "노트의 달인 I" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Meester der notities I" + "value" : "Master i Merknader I" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Master i Merknader I" + "value" : "Meester der notities I" } }, "pl" : { @@ -54563,16 +54064,16 @@ "value" : "총 900일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in totaal 900 dagen in" + "value" : "Logg inn totalt 900 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn totalt 900 dager" + "value" : "Log in totaal 900 dagen in" } }, "pl" : { @@ -54748,16 +54249,16 @@ "value" : "노트의 달인 II" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Meester der notities II" + "value" : "Master i Merknader II" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Master i Merknader II" + "value" : "Meester der notities II" } }, "pl" : { @@ -54933,16 +54434,16 @@ "value" : "총 1,000일 로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Log in voor een totaal van 1.000 dagen" + "value" : "Logg inn totalt 1000 dager" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn totalt 1000 dager" + "value" : "Log in voor een totaal van 1.000 dagen" } }, "pl" : { @@ -55124,16 +54625,16 @@ "value" : "Misskey를 사용해 주셔서 감사합니다!" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bedankt voor het gebruiken van Misskey!" + "value" : "Takk for at du bruker Misskey!" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Takk for at du bruker Misskey!" + "value" : "Bedankt voor het gebruiken van Misskey!" } }, "pl" : { @@ -55309,16 +54810,16 @@ "value" : "노트의 달인 III" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Meester der notities III" + "value" : "Master i note III" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Master i note III" + "value" : "Meester der notities III" } }, "pl" : { @@ -55494,16 +54995,16 @@ "value" : "계정을 고양이로 표시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Markeer jouw account als een kat" + "value" : "Merk kontoen din som en katt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Merk kontoen din som en katt" + "value" : "Markeer jouw account als een kat" } }, "pl" : { @@ -55679,16 +55180,16 @@ "value" : "나중에 이름을 정할게요." } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ik zal je later een naam geven." + "value" : "Jeg skal gi deg et navn senere." } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Jeg skal gi deg et navn senere." + "value" : "Ik zal je later een naam geven." } }, "pl" : { @@ -55870,16 +55371,16 @@ "value" : "나는 고양이입니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ik ben een kat" + "value" : "Jeg er en katt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Jeg er en katt" + "value" : "Ik ben een kat" } }, "pl" : { @@ -56055,16 +55556,16 @@ "value" : "다른 사람이 당신의 노트를 즐겨찾기하도록 하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Laat iemand anders een van je notities favoriet maken" + "value" : "Har noen andre satt på en av notatene dine" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Har noen andre satt på en av notatene dine" + "value" : "Laat iemand anders een van je notities favoriet maken" } }, "pl" : { @@ -56240,16 +55741,16 @@ "value" : "별 찾기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Zoek sterren" + "value" : "Søker etter stjerner" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Søker etter stjerner" + "value" : "Zoek sterren" } }, "pl" : { @@ -56425,16 +55926,16 @@ "value" : "첫 번째 노트를 클립하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Knip je eerste notitie" + "value" : "Klipp din første notat" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Klipp din første notat" + "value" : "Knip je eerste notitie" } }, "pl" : { @@ -56610,16 +56111,16 @@ "value" : "필요하다... 클립하다..." } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Mosterd... klem..." + "value" : "Må... klipp..." } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Må... klipp..." + "value" : "Mosterd... klem..." } }, "pl" : { @@ -56795,16 +56296,16 @@ "value" : "게시 후 1분 이내에 노트를 삭제하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Een notitie verwijderen binnen een minuut na het plaatsen ervan" + "value" : "Slett et notat i løpet av ett minutt etter å poste det" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett et notat i løpet av ett minutt etter å poste det" + "value" : "Een notitie verwijderen binnen een minuut na het plaatsen ervan" } }, "pl" : { @@ -56980,16 +56481,16 @@ "value" : "신경 쓰지 마세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Onthoud" + "value" : "Glem det" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Glem det" + "value" : "Onthoud" } }, "pl" : { @@ -57165,16 +56666,16 @@ "value" : "첫 번째 노트를 즐겨찾기에 추가하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Favoriet je eerste notitie" + "value" : "Favoritt ditt første notat" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Favoritt ditt første notat" + "value" : "Favoriet je eerste notitie" } }, "pl" : { @@ -57350,13 +56851,13 @@ "value" : "별을 바라보는 사람" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Stargazer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Stargazer" @@ -57535,16 +57036,16 @@ "value" : "첫 번째 노트를 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Plaats je eerste notitie" + "value" : "Legg inn ditt første notat" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Legg inn ditt første notat" + "value" : "Plaats je eerste notitie" } }, "pl" : { @@ -57720,16 +57221,16 @@ "value" : "Misskey와 즐거운 시간을 보내세요!" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Veel tijd met Misskey!" + "value" : "Ha en god tid med Misskey!" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ha en god tid med Misskey!" + "value" : "Veel tijd met Misskey!" } }, "pl" : { @@ -57905,16 +57406,16 @@ "value" : "내 msky 설정 중입니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "stel gewoon mijn msky op" + "value" : "Nettopp msky min" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nettopp msky min" + "value" : "stel gewoon mijn msky op" } }, "pl" : { @@ -58096,16 +57597,16 @@ "value" : "10개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Plaats 10 notities" + "value" : "Post 10 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 10 notater" + "value" : "Plaats 10 notities" } }, "pl" : { @@ -58281,16 +57782,16 @@ "value" : "몇 개의 노트" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Sommige notities" + "value" : "Noen notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Noen notater" + "value" : "Sommige notities" } }, "pl" : { @@ -58472,16 +57973,16 @@ "value" : "100개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 100 notities" + "value" : "Post 100 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 100 notater" + "value" : "Post 100 notities" } }, "pl" : { @@ -58663,16 +58164,16 @@ "value" : "많은 노트" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Veel notities" + "value" : "Mange notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Mange notater" + "value" : "Veel notities" } }, "pl" : { @@ -58854,16 +58355,16 @@ "value" : "500개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Plaats 500 notities" + "value" : "Post 500 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 500 notater" + "value" : "Plaats 500 notities" } }, "pl" : { @@ -59039,16 +58540,16 @@ "value" : "노트에 담긴" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gedekt in notities" + "value" : "Dekket i notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Dekket i notater" + "value" : "Gedekt in notities" } }, "pl" : { @@ -59230,16 +58731,16 @@ "value" : "1,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 1.000 notities" + "value" : "Post 1000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 1000 notater" + "value" : "Post 1.000 notities" } }, "pl" : { @@ -59421,16 +58922,16 @@ "value" : "산더미 같은 노트" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Een berg notities" + "value" : "A mountain of notes" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "A mountain of notes" + "value" : "Een berg notities" } }, "pl" : { @@ -59612,16 +59113,16 @@ "value" : "5,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 5.000 notities" + "value" : "Post 5000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 5000 notater" + "value" : "Post 5.000 notities" } }, "pl" : { @@ -59797,16 +59298,16 @@ "value" : "넘쳐나는 노트" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Notities overvloeien" + "value" : "Noter som flyter over" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Noter som flyter over" + "value" : "Notities overvloeien" } }, "pl" : { @@ -59988,16 +59489,16 @@ "value" : "10,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 10.000 notities" + "value" : "Post 10000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 10000 notater" + "value" : "Post 10.000 notities" } }, "pl" : { @@ -60173,16 +59674,16 @@ "value" : "슈퍼노트" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Supernotitie" + "value" : "Supernota" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Supernota" + "value" : "Supernotitie" } }, "pl" : { @@ -60364,16 +59865,16 @@ "value" : "20,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 20.000 notities" + "value" : "Post 20 000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 20 000 notater" + "value" : "Post 20.000 notities" } }, "pl" : { @@ -60555,16 +60056,16 @@ "value" : "더 많은 노트가 필요해요..." } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Nood... aantekeningen..." + "value" : "Trenger mer... mer... notater..." } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Trenger mer... mer... notater..." + "value" : "Nood... aantekeningen..." } }, "pl" : { @@ -60746,16 +60247,16 @@ "value" : "30,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 30.000 notities" + "value" : "Post 30.000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 30.000 notater" + "value" : "Post 30.000 notities" } }, "pl" : { @@ -60937,16 +60438,16 @@ "value" : "노트 노트 노트!" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Notities notities" + "value" : "Notater notater!" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Notater notater!" + "value" : "Notities notities" } }, "pl" : { @@ -61128,16 +60629,16 @@ "value" : "40,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 40.000 notities" + "value" : "Post 40.000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 40.000 notater" + "value" : "Post 40.000 notities" } }, "pl" : { @@ -61319,16 +60820,16 @@ "value" : "노트 공장" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Notitie fabriek" + "value" : "Merknader fabrikk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Merknader fabrikk" + "value" : "Notitie fabriek" } }, "pl" : { @@ -61510,16 +61011,16 @@ "value" : "50,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 50.000 notities" + "value" : "Post 50,000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 50,000 notater" + "value" : "Post 50.000 notities" } }, "pl" : { @@ -61701,16 +61202,16 @@ "value" : "노트의 행성" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Planet van notities" + "value" : "Planet of notes" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Planet of notes" + "value" : "Planet van notities" } }, "pl" : { @@ -61892,16 +61393,16 @@ "value" : "60,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 60.000 notities" + "value" : "Post 60.000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 60.000 notater" + "value" : "Post 60.000 notities" } }, "pl" : { @@ -62083,16 +61584,16 @@ "value" : "노트 퀘이사" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Quasar Notitie" + "value" : "Merk kvasar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Merk kvasar" + "value" : "Quasar Notitie" } }, "pl" : { @@ -62274,16 +61775,16 @@ "value" : "70,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 70,000 notities" + "value" : "Post 70,000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 70,000 notater" + "value" : "Post 70,000 notities" } }, "pl" : { @@ -62465,16 +61966,16 @@ "value" : "노트 블랙홀" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Zwart gat notitie" + "value" : "Noter svart hull" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Noter svart hull" + "value" : "Zwart gat notitie" } }, "pl" : { @@ -62656,16 +62157,16 @@ "value" : "80,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 80,000 notities" + "value" : "Post 80,000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 80,000 notater" + "value" : "Post 80,000 notities" } }, "pl" : { @@ -62847,16 +62348,16 @@ "value" : "노트 은하" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Notitie sterrenstelsel" + "value" : "Note galakse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Note galakse" + "value" : "Notitie sterrenstelsel" } }, "pl" : { @@ -63038,16 +62539,16 @@ "value" : "90,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 90,000 notities" + "value" : "Post 90.000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 90.000 notater" + "value" : "Post 90,000 notities" } }, "pl" : { @@ -63229,16 +62730,16 @@ "value" : "노트 우주" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Noot universum" + "value" : "Notat univers" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Notat univers" + "value" : "Noot universum" } }, "pl" : { @@ -63420,16 +62921,16 @@ "value" : "100,000개 노트 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Post 100.000 notities" + "value" : "Post 100 000 notater" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post 100 000 notater" + "value" : "Post 100.000 notities" } }, "pl" : { @@ -63605,16 +63106,16 @@ "value" : "당신은 할 말이 많습니다." } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "U hebt zeker veel te zeggen." + "value" : "Du er sikker på at du har mye å si." } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Du er sikker på at du har mye å si." + "value" : "U hebt zeker veel te zeggen." } }, "pl" : { @@ -63796,16 +63297,16 @@ "value" : "모든 노트는 우리에게 속해있습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "ALLES UW MET BELONG OM TE ONS" + "value" : "ALLE DIN merk – FEIL TIL USAs" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "ALLE DIN merk – FEIL TIL USAs" + "value" : "ALLES UW MET BELONG OM TE ONS" } }, "pl" : { @@ -63981,16 +63482,16 @@ "value" : "동시에 3개 이상의 창을 엽니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Heb ten minste 3 vensters open op hetzelfde moment" + "value" : "Ha minst tre vinduer åpne samtidig" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ha minst tre vinduer åpne samtidig" + "value" : "Heb ten minste 3 vensters open op hetzelfde moment" } }, "pl" : { @@ -64166,16 +63667,16 @@ "value" : "멀티윈도우" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Multi-venster" + "value" : "Multi-Vindu" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Multi-Vindu" + "value" : "Multi-venster" } }, "pl" : { @@ -64351,16 +63852,16 @@ "value" : "스크래치패드에서 \"hello world\" 출력하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Uitvoer \"hallo wereld\" in het Scratchpad" + "value" : "Utgang \"hallo verden\" på Scratchpad" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Utgang \"hallo verden\" på Scratchpad" + "value" : "Uitvoer \"hallo wereld\" in het Scratchpad" } }, "pl" : { @@ -64542,16 +64043,16 @@ "value" : "안녕하세요, 세계!" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Hallo, wereld!" + "value" : "Hallo, verden!" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hallo, verden!" + "value" : "Hallo, wereld!" } }, "pl" : { @@ -64727,16 +64228,16 @@ "value" : "계정 생성 후 1년이 지났습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Eén jaar is verstreken sinds uw account is aangemaakt" + "value" : "Ett år har gått siden din konto ble opprettet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ett år har gått siden din konto ble opprettet" + "value" : "Eén jaar is verstreken sinds uw account is aangemaakt" } }, "pl" : { @@ -64912,16 +64413,16 @@ "value" : "1주년" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Één Verjaardag" + "value" : "Et års jubileum" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Et års jubileum" + "value" : "Één Verjaardag" } }, "pl" : { @@ -65097,16 +64598,16 @@ "value" : "계정 생성 후 2년이 지났습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Twee jaar zijn verstreken sinds het aanmaken van uw account" + "value" : "Det er gått 2 år siden din konto ble opprettet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Det er gått 2 år siden din konto ble opprettet" + "value" : "Twee jaar zijn verstreken sinds het aanmaken van uw account" } }, "pl" : { @@ -65282,16 +64783,16 @@ "value" : "2주년" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Tweejarig Verjaardag" + "value" : "To års jubileum" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "To års jubileum" + "value" : "Tweejarig Verjaardag" } }, "pl" : { @@ -65467,16 +64968,16 @@ "value" : "계정 생성 후 3년이 지났습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Drie jaar zijn verstreken sinds de aanmaak van uw account" + "value" : "Det er gått tre år siden din konto ble opprettet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Det er gått tre år siden din konto ble opprettet" + "value" : "Drie jaar zijn verstreken sinds de aanmaak van uw account" } }, "pl" : { @@ -65652,16 +65153,16 @@ "value" : "3주년" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Drie jaar Verjaardag" + "value" : "Tre års jubileum" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tre års jubileum" + "value" : "Drie jaar Verjaardag" } }, "pl" : { @@ -65837,16 +65338,16 @@ "value" : "00:00에 노트를 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Plaats een notitie om 00:00" + "value" : "Skriv en melding klokken 00:00" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Skriv en melding klokken 00:00" + "value" : "Plaats een notitie om 00:00" } }, "pl" : { @@ -66022,13 +65523,13 @@ "value" : "클릭 클릭 클릭 쨍" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Click Click Click Claaang" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Click Click Click Claaang" @@ -66207,16 +65708,16 @@ "value" : "시계 맞추기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Sprekende Klok" + "value" : "Snakker klokke" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Snakker klokke" + "value" : "Sprekende Klok" } }, "pl" : { @@ -66392,16 +65893,16 @@ "value" : "늦은 밤에 노트를 게시하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Plaats een notitie laat in de nacht" + "value" : "Legg inn en melding sent om natten" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Legg inn en melding sent om natten" + "value" : "Plaats een notitie laat in de nacht" } }, "pl" : { @@ -66577,16 +66078,16 @@ "value" : "이제 잘 시간입니다." } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Het is hoog tijd om naar bed te gaan." + "value" : "Det er på tide å gå til sengs." } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Det er på tide å gå til sengs." + "value" : "Het is hoog tijd om naar bed te gaan." } }, "pl" : { @@ -66762,16 +66263,16 @@ "value" : "야행성" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Nachtelijk" + "value" : "Nattlig" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nattlig" + "value" : "Nachtelijk" } }, "pl" : { @@ -66947,16 +66448,16 @@ "value" : "프로필을 설정하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Stel je profiel in" + "value" : "Sett opp din profil" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sett opp din profil" + "value" : "Stel je profiel in" } }, "pl" : { @@ -67132,16 +66633,16 @@ "value" : "잘 준비했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Goed bereid" + "value" : "Godt forberedt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Godt forberedt" + "value" : "Goed bereid" } }, "pl" : { @@ -67317,16 +66818,16 @@ "value" : "게시된 후 3초 이내에 100자 이상의 메모에 반응하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Reageer op een notitie die meer dan 100 tekens lang is binnen 3 seconden nadat deze is gepost" + "value" : "React på et notat som er over 100 tegn innenfor 3 sekunder etter at det er postet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "React på et notat som er over 100 tegn innenfor 3 sekunder etter at det er postet" + "value" : "Reageer op een notitie die meer dan 100 tekens lang is binnen 3 seconden nadat deze is gepost" } }, "pl" : { @@ -67502,16 +67003,16 @@ "value" : "정말로 그걸 읽었나요?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Heb je dat echt gelezen?" + "value" : "Har du virkelig lest det?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Har du virkelig lest det?" + "value" : "Heb je dat echt gelezen?" } }, "pl" : { @@ -67687,16 +67188,16 @@ "value" : "자신의 노트를 인용하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Citeer uw eigen notitie" + "value" : "Siter ditt eget notat" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Siter ditt eget notat" + "value" : "Citeer uw eigen notitie" } }, "pl" : { @@ -67872,16 +67373,16 @@ "value" : "자기 참조" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Zelfreferentie" + "value" : "Selv-referanse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Selv-referanse" + "value" : "Zelfreferentie" } }, "pl" : { @@ -68063,16 +67564,16 @@ "value" : "이름을 \"syuilo\"로 설정하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Stel je naam in op \"syuilo\"" + "value" : "Sett ditt navn til \"syuilo\"" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sett ditt navn til \"syuilo\"" + "value" : "Stel je naam in op \"syuilo\"" } }, "pl" : { @@ -68254,16 +67755,16 @@ "value" : "신의 복잡성" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "God Complex" + "value" : "Kompleks Gud" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kompleks Gud" + "value" : "God Complex" } }, "pl" : { @@ -68439,16 +67940,16 @@ "value" : "극도로 짧은 시간 안에 알림 테스트를 반복적으로 트리거하세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Trigger de notificatie-test herhaaldelijk binnen extreem korte tijd" + "value" : "Utløs varslingstesten gjentatte ganger i løpet av svært kort tid" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Utløs varslingstesten gjentatte ganger i løpet av svært kort tid" + "value" : "Trigger de notificatie-test herhaaldelijk binnen extreem korte tijd" } }, "pl" : { @@ -68624,16 +68125,16 @@ "value" : "테스트 오버플로우" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Overloop testen" + "value" : "Prøving av overflyt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Prøving av overflyt" + "value" : "Overloop testen" } }, "pl" : { @@ -68809,16 +68310,16 @@ "value" : "튜토리얼 완료" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Tutorial voltooid" + "value" : "Opplæring fullført" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Opplæring fullført" + "value" : "Tutorial voltooid" } }, "pl" : { @@ -68994,16 +68495,16 @@ "value" : "Misskey 초급 과정 졸업장" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Misskey Elementary Cursus Diploma" + "value" : "Misskey Elementary Course Diploma" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Misskey Elementary Course Diploma" + "value" : "Misskey Elementary Cursus Diploma" } }, "pl" : { @@ -69179,16 +68680,16 @@ "value" : "업적 목록을 최소 3분 동안 봅니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bekijk je lijst met prestaties voor ten minste 3 minuten" + "value" : "Se listen over prestasjoner i minst 3 minutter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Se listen over prestasjoner i minst 3 minutter" + "value" : "Bekijk je lijst met prestaties voor ten minste 3 minuten" } }, "pl" : { @@ -69364,16 +68865,16 @@ "value" : "좋아요 업적" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Likes Prestaties" + "value" : "Liker Prestasjoner" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liker Prestasjoner" + "value" : "Likes Prestaties" } }, "pl" : { @@ -69549,16 +69050,16 @@ "value" : "귀하의 인스턴스 차트를 봅니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bekijk de grafieken van je instantie" + "value" : "Vis din instans sine karter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis din instans sine karter" + "value" : "Bekijk de grafieken van je instantie" } }, "pl" : { @@ -69734,16 +69235,16 @@ "value" : "분석가" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Analist" + "value" : "Analytiker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Analytiker" + "value" : "Analist" } }, "pl" : { @@ -69888,16 +69389,16 @@ "value" : "お気に入り" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Favoriete" + "value" : "Favoritt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Favoritt" + "value" : "Favoriete" } }, "pl" : { @@ -70060,16 +69561,16 @@ "value" : "팔로우" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgen" + "value" : "Følg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følg" + "value" : "Volgen" } }, "pl" : { @@ -70214,16 +69715,16 @@ "value" : "お気に入り" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Favorieten" + "value" : "Favoritter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Favoritter" + "value" : "Favorieten" } }, "pl" : { @@ -70262,12 +69763,6 @@ "value" : "Favoriter" } }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Favoriler" - } - }, "uk" : { "stringUnit" : { "state" : "translated", @@ -70386,16 +69881,16 @@ "value" : "추천" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Aanbevolen" + "value" : "Anbefalt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Anbefalt" + "value" : "Aanbevolen" } }, "pl" : { @@ -70576,16 +70071,16 @@ "value" : "팔로잉" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgt" + "value" : "Følger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følger" + "value" : "Volgt" } }, "pl" : { @@ -70730,16 +70225,16 @@ "value" : "所有しています" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Eigendom" + "value" : "Eid" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Eid" + "value" : "Eigendom" } }, "pl" : { @@ -70778,12 +70273,6 @@ "value" : "Ägd" } }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sahip olduklarım" - } - }, "uk" : { "stringUnit" : { "state" : "translated", @@ -70866,13 +70355,13 @@ "value" : "Unfavourite" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Unfavourite" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Unfavourite" @@ -70914,12 +70403,6 @@ "value" : "Unfavourite" } }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Favorilerden Çıkar" - } - }, "uk" : { "stringUnit" : { "state" : "translated", @@ -71002,16 +70485,16 @@ "value" : "フォローを解除" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ontvolgen" + "value" : "Ikke følg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ikke følg" + "value" : "Ontvolgen" } }, "pl" : { @@ -71050,12 +70533,6 @@ "value" : "Sluta följa" } }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Takibi bırak" - } - }, "uk" : { "stringUnit" : { "state" : "translated", @@ -71074,132 +70551,142 @@ "localizations" : { "ar" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "لقد حصلت على انجاز" } }, "cs" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Získali jste úspěch" } }, "da" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Du har opnået en bedrift" } }, "de" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Sie haben eine Errungenschaft verdient" } }, "el" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Έχετε κερδίσει ένα επίτευγμα" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "You've earned an achievement" + "value" : "You've earned an achievement %@ %@ " } }, "es" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Has conseguido un logro" } }, "fi" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Olet ansainnut saavutuksen" } }, "fr" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Vous avez gagné un accomplissement" } }, "it" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Risultato raggiunto" } }, "ja" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "アチーブメントを獲得しました" } }, - "nl" : { + "nb" : { "stringUnit" : { - "state" : "translated", - "value" : "Je hebt een prestatie verdiend" + "state" : "needs_review", + "value" : "Du har tjent en prestasjon" } }, - "no" : { + "nl" : { "stringUnit" : { - "state" : "translated", - "value" : "Du har tjent en prestasjon" + "state" : "needs_review", + "value" : "Je hebt een prestatie verdiend" } }, "pl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Zdobyłeś osiągnięcie" } }, "pt" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Você ganhou uma conquista" } }, "pt-BR" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Você ganhou uma conquista" } }, "ro" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Ai câștigat o realizare" } }, "ru" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Вы получили достижение" } }, "sv" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Du har förtjänat en prestation" } }, "uk" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Ви заробили досягнення" } }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "您已经获得了成就" } } } }, + "misskey_notification_achievement_earned %@ %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You've earned an achievement %1$@ %2$@" + } + } + } + }, "misskey_notification_app" : { "localizations" : { "af" : { @@ -71304,13 +70791,13 @@ "value" : "앱" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "App" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "App" @@ -71391,6 +70878,7 @@ } }, "misskey_notification_follow" : { + "extractionState" : "stale", "localizations" : { "af" : { "stringUnit" : { @@ -71494,16 +70982,16 @@ "value" : "당신을 팔로우했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "volgt jou nu" + "value" : "fulgte deg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "fulgte deg" + "value" : "volgt jou nu" } }, "pl" : { @@ -71678,16 +71166,16 @@ "value" : "당신의 팔로우 요청을 수락했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "heeft je volgverzoek geaccepteerd" + "value" : "aksepterte din forespørsel" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "aksepterte din forespørsel" + "value" : "heeft je volgverzoek geaccepteerd" } }, "pl" : { @@ -71765,6 +71253,7 @@ } }, "misskey_notification_mention" : { + "extractionState" : "stale", "localizations" : { "af" : { "stringUnit" : { @@ -71868,16 +71357,16 @@ "value" : "당신을 언급했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "noemde je" + "value" : "nevnte deg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "nevnte deg" + "value" : "noemde je" } }, "pl" : { @@ -71955,6 +71444,7 @@ } }, "misskey_notification_poll_ended" : { + "extractionState" : "stale", "localizations" : { "af" : { "stringUnit" : { @@ -72052,16 +71542,16 @@ "value" : "참여했던 설문조사가 종료되었습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Een poll waaraan u deelnam, is beëindigd" + "value" : "En avstemming du deltok i er avsluttet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "En avstemming du deltok i er avsluttet" + "value" : "Een poll waaraan u deelnam, is beëindigd" } }, "pl" : { @@ -72242,16 +71732,16 @@ "value" : "인용했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "geciteerd" + "value" : "sitert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "sitert" + "value" : "geciteerd" } }, "pl" : { @@ -72432,16 +71922,16 @@ "value" : "다시 게시했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "gepost" + "value" : "repostet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "repostet" + "value" : "gepost" } }, "pl" : { @@ -72519,6 +72009,7 @@ } }, "misskey_notification_receive_follow_request" : { + "extractionState" : "stale", "localizations" : { "af" : { "stringUnit" : { @@ -72616,16 +72107,16 @@ "value" : "당신을 팔로우 요청했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "aangevraagd om je te volgen" + "value" : "bedt om å følge deg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "bedt om å følge deg" + "value" : "aangevraagd om je te volgen" } }, "pl" : { @@ -72703,6 +72194,7 @@ } }, "misskey_notification_renote" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -72770,13 +72262,13 @@ "value" : "renote" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "renote" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "renote" @@ -72930,16 +72422,16 @@ "value" : "답장" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beantwoorden" + "value" : "svar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "svar" + "value" : "Beantwoorden" } }, "pl" : { @@ -73017,6 +72509,7 @@ } }, "misskey_notification_unknwon" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -73084,16 +72577,16 @@ "value" : "Unknwn" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ontbrekend" + "value" : "Uknuste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Uknuste" + "value" : "Ontbrekend" } }, "pl" : { @@ -73244,16 +72737,16 @@ "value" : "이 게시물의 문제는 무엇인가요?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Wat is het probleem met dit bericht?" + "value" : "Hva er problemet med dette innlegget?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hva er problemet med dette innlegget?" + "value" : "Wat is het probleem met dit bericht?" } }, "pl" : { @@ -73398,16 +72891,16 @@ "value" : "ここに問題を入力してください" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voer het probleem hier in" + "value" : "Skriv inn problemet her" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Skriv inn problemet her" + "value" : "Voer het probleem hier in" } }, "pl" : { @@ -73564,16 +73057,16 @@ "value" : "신고하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporteren" + "value" : "Rapporter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporter" + "value" : "Rapporteren" } }, "pl" : { @@ -73754,16 +73247,16 @@ "value" : "혼합" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gemengd" + "value" : "Blandet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Blandet" + "value" : "Gemengd" } }, "pl" : { @@ -73842,131 +73335,11 @@ }, "Model" : { "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "الموديل" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vzor" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Model" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Modell" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Μοντέλο" - } - }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Model" } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Modelo" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Malli" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Modélisation" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Modello" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "モデル" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Model" - } - }, - "no" : { - "stringUnit" : { - "state" : "translated", - "value" : "Modell" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wzór" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Modelo" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Modelo" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Model" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Модель" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Modell" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Модель" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "模型" - } } } }, @@ -74074,16 +73447,16 @@ "value" : "더보기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Meer" + "value" : "Mer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Mer" + "value" : "Meer" } }, "pl" : { @@ -74264,16 +73637,16 @@ "value" : "더보기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Meer" + "value" : "Mer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Mer" + "value" : "Meer" } }, "pl" : { @@ -74454,16 +73827,16 @@ "value" : "음소거" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Dempen" + "value" : "Demp" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Demp" + "value" : "Dempen" } }, "pl" : { @@ -74608,16 +73981,16 @@ "value" : "このユーザーをミュートしてもよろしいですか?" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Weet je zeker dat je deze gebruiker wilt muten?" + "value" : "Er du sikker på at du vil dempe denne brukeren?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Er du sikker på at du vil dempe denne brukeren?" + "value" : "Weet je zeker dat je deze gebruiker wilt muten?" } }, "pl" : { @@ -74738,16 +74111,16 @@ "value" : "ユーザーをミュート" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gebruiker dempen" + "value" : "Demp bruker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Demp bruker" + "value" : "Gebruiker dempen" } }, "pl" : { @@ -74926,131 +74299,11 @@ }, "No models available" : { "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لا توجد نماذج متاحة" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Žádné dostupné modely" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ingen tilgængelige modeller" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keine Modelle verfügbar" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Δεν υπάρχουν διαθέσιμα μοντέλα" - } - }, "en" : { "stringUnit" : { "state" : "translated", "value" : "No models available" } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "No hay modelos disponibles" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ei malleja saatavilla" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aucun modèle disponible" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nessun modello disponibile" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "利用可能なモデルがありません" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Geen modellen beschikbaar" - } - }, - "no" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ingen modeller tilgjengelig" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Brak dostępnych modeli" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Não há modelos disponíveis" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Não há modelos disponíveis" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nu există modele disponibile" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Нет доступных моделей" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Inga modeller tillgängliga" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Немає доступних моделей" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "没有可用的模型" - } } } }, @@ -75116,16 +74369,16 @@ "value" : "%@ はまだ完了していません。" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Nog niet klaar voor %@" + "value" : "Ikke ferdig enda for %@" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ikke ferdig enda for %@" + "value" : "Nog niet klaar voor %@" } }, "pl" : { @@ -75246,16 +74499,16 @@ "value" : "投稿を送信できませんでした" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bericht versturen mislukt" + "value" : "Kunne ikke sende innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kunne ikke sende innlegg" + "value" : "Bericht versturen mislukt" } }, "pl" : { @@ -75382,16 +74635,16 @@ "value" : "投稿を送信しました" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bericht verzonden" + "value" : "Post sendt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post sendt" + "value" : "Bericht verzonden" } }, "pl" : { @@ -75548,16 +74801,16 @@ "value" : "로그인 만료, 다시 로그인 해주세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Inloggen verlopen, gelieve opnieuw in te loggen" + "value" : "Innlogging utløpt, vennligst logg inn igjen" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlogging utløpt, vennligst logg inn igjen" + "value" : "Inloggen verlopen, gelieve opnieuw in te loggen" } }, "pl" : { @@ -75708,16 +74961,16 @@ "value" : "画像の保存に失敗しました" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opslaan van afbeelding mislukt" + "value" : "Kunne ikke lagre bilde" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kunne ikke lagre bilde" + "value" : "Opslaan van afbeelding mislukt" } }, "pl" : { @@ -75844,16 +75097,16 @@ "value" : "画像をライブラリに保存しました" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Afbeelding opgeslagen in bibliotheek" + "value" : "Bilde lagret i biblioteket" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Bilde lagret i biblioteket" + "value" : "Afbeelding opgeslagen in bibliotheek" } }, "pl" : { @@ -76010,16 +75263,16 @@ "value" : "모두" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Allemaal" + "value" : "Alle" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Alle" + "value" : "Allemaal" } }, "pl" : { @@ -76200,16 +75453,16 @@ "value" : "댓글" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opmerking" + "value" : "Kommentar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kommentar" + "value" : "Opmerking" } }, "pl" : { @@ -76390,16 +75643,16 @@ "value" : "좋아요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "vind-ik-leuk" + "value" : "Lik" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lik" + "value" : "vind-ik-leuk" } }, "pl" : { @@ -76544,16 +75797,16 @@ "value" : "メンション" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vermelding" + "value" : "Nevn" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nevn" + "value" : "Vermelding" } }, "pl" : { @@ -76674,16 +75927,16 @@ "value" : "Notification" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Notificatie" + "value" : "Varsling" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Varsling" + "value" : "Notificatie" } }, "pl" : { @@ -76846,13 +76099,13 @@ "value" : "확인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Ok" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Ok" @@ -77000,16 +76253,16 @@ "value" : "ブラウダーで開く" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Openen in blader" + "value" : "Åpne i nettleser" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Åpne i nettleser" + "value" : "Openen in blader" } }, "pl" : { @@ -77064,131 +76317,11 @@ }, "OpenAI model used for translation and summary" : { "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "نموذج OpenAI المستخدم للترجمة والموجز" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "OpenAI model použitý pro překlad a shrnutí" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "OpenAI model brugt til oversættelse og resumé" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "OpenAI-Modell für Übersetzung und Zusammenfassung" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Μοντέλο OpenAI που χρησιμοποιείται για μετάφραση και περίληψη" - } - }, "en" : { "stringUnit" : { "state" : "translated", "value" : "OpenAI model used for translation and summary" } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Modelo OpenAI utilizado para la traducción y resumen" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "OpenAI malli käytetään kääntämiseen ja tiivistelmään" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Modèle OpenAI utilisé pour la traduction et le résumé" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Modello OpenAI utilizzato per traduzione e riepilogo" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "翻訳と概要に使用されるOpenAIモデル" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "OpenAI-model gebruikt voor vertaling en samenvatting" - } - }, - "no" : { - "stringUnit" : { - "state" : "translated", - "value" : "OpenAI-modell brukt for oversettelse og sammendrag" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Model OpenAI używany do tłumaczenia i podsumowania" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Modelo OpenAI usado para tradução e resumo" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Modelo OpenAI usado para tradução e resumo" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Modelul OpenAI folosit pentru traducere și rezumat" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Модель OpenAI, используемая для перевода и сводки" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "OpenAI-modell som används för översättning och sammanfattning" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Модель OpenAI, яка використовується для перекладу та резюме" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "用于翻译和摘要的 OpenAI 模型" - } } } }, @@ -77260,16 +76393,16 @@ "value" : "OPMLからインポート" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Importeren uit OPML" + "value" : "Importer fra OPML" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Importer fra OPML" + "value" : "Importeren uit OPML" } }, "pl" : { @@ -77308,12 +76441,6 @@ "value" : "Importera från OPML" } }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "OPML'den içe aktar" - } - }, "uk" : { "stringUnit" : { "state" : "translated", @@ -77396,16 +76523,16 @@ "value" : "OPMLからインポート" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Importeren uit OPML" + "value" : "Importer fra OPML" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Importer fra OPML" + "value" : "Importeren uit OPML" } }, "pl" : { @@ -77444,12 +76571,6 @@ "value" : "Importera från OPML" } }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "OPML'den içe aktar" - } - }, "uk" : { "stringUnit" : { "state" : "translated", @@ -77532,16 +76653,16 @@ "value" : "このページにアクセスするには再ログインが必要です" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "U moet opnieuw inloggen om toegang te krijgen tot deze pagina" + "value" : "Du må logge inn på nytt for å få tilgang til denne siden" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Du må logge inn på nytt for å få tilgang til denne siden" + "value" : "U moet opnieuw inloggen om toegang te krijgen tot deze pagina" } }, "pl" : { @@ -77662,16 +76783,16 @@ "value" : "アクセスが拒否されました" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toestemming geweigerd" + "value" : "Tilgang nektet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tilgang nektet" + "value" : "Toestemming geweigerd" } }, "pl" : { @@ -77822,16 +76943,16 @@ "value" : "투표가 만료됨" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Poll verlopen" + "value" : "Avstemningen utløpt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Avstemningen utløpt" + "value" : "Poll verlopen" } }, "pl" : { @@ -77976,16 +77097,16 @@ "value" : "有効期限:" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verlopen op" + "value" : "Utløpt på" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Utløpt på" + "value" : "Verlopen op" } }, "pl" : { @@ -78142,16 +77263,16 @@ "value" : "투표" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Stemming" + "value" : "Stem" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Stem" + "value" : "Stemming" } }, "pl" : { @@ -78332,16 +77453,16 @@ "value" : "게시물" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Berichten" + "value" : "Innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg" + "value" : "Berichten" } }, "pl" : { @@ -78522,16 +77643,16 @@ "value" : "좋아요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vind-ik-leuk" + "value" : "Liker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liker" + "value" : "Vind-ik-leuk" } }, "pl" : { @@ -78712,16 +77833,16 @@ "value" : "미디어" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Medium" + "value" : "Medier" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Medier" + "value" : "Medium" } }, "pl" : { @@ -78902,16 +78023,16 @@ "value" : "게시물 및 회신" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Berichten en antwoorden" + "value" : "Innlegg og svar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg og svar" + "value" : "Berichten en antwoorden" } }, "pl" : { @@ -79092,16 +78213,16 @@ "value" : "게시물" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Berichten" + "value" : "Innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg" + "value" : "Berichten" } }, "pl" : { @@ -79282,16 +78403,16 @@ "value" : "인용하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Offerte" + "value" : "Sitat" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sitat" + "value" : "Offerte" } }, "pl" : { @@ -79472,16 +78593,16 @@ "value" : "반응 추가" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Reactie toevoegen" + "value" : "Legg til reaksjon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Legg til reaksjon" + "value" : "Reactie toevoegen" } }, "pl" : { @@ -79662,16 +78783,16 @@ "value" : "반응 제거" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Reactie verwijderen" + "value" : "Fjern reaksjon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Fjern reaksjon" + "value" : "Reactie verwijderen" } }, "pl" : { @@ -79846,16 +78967,16 @@ "value" : "거부" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Afwijzen" + "value" : "Avvis" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Avvis" + "value" : "Afwijzen" } }, "pl" : { @@ -80036,16 +79157,16 @@ "value" : "차단됨" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Geblokkeerd" + "value" : "Blokkert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Blokkert" + "value" : "Geblokkeerd" } }, "pl" : { @@ -80226,16 +79347,16 @@ "value" : "팔로우" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgen" + "value" : "Følg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følg" + "value" : "Volgen" } }, "pl" : { @@ -80416,16 +79537,16 @@ "value" : "팔로잉" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgt" + "value" : "Følger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følger" + "value" : "Volgt" } }, "pl" : { @@ -80606,16 +79727,16 @@ "value" : "당신을 팔로우합니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgt jou" + "value" : "Følger deg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følger deg" + "value" : "Volgt jou" } }, "pl" : { @@ -80796,16 +79917,16 @@ "value" : "요청됨" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Aangevraagd" + "value" : "Forespurt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Forespurt" + "value" : "Aangevraagd" } }, "pl" : { @@ -80986,16 +80107,16 @@ "value" : "답장" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beantwoorden" + "value" : "Svar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Svar" + "value" : "Beantwoorden" } }, "pl" : { @@ -81170,16 +80291,16 @@ "value" : "%@에게 답장" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Antwoord op %@" + "value" : "Svar til %@" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Svar til %@" + "value" : "Antwoord op %@" } }, "pl" : { @@ -81360,16 +80481,16 @@ "value" : "보고서" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporteren" + "value" : "Rapporter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporter" + "value" : "Rapporteren" } }, "pl" : { @@ -81550,16 +80671,16 @@ "value" : "리트윗" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Retweet" + "value" : "Gjenkjenn" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Gjenkjenn" + "value" : "Retweet" } }, "pl" : { @@ -81734,16 +80855,16 @@ "value" : "리트윗 제거" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijder retweet" + "value" : "Fjern innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Fjern innlegg" + "value" : "Verwijder retweet" } }, "pl" : { @@ -81924,16 +81045,16 @@ "value" : "추가" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toevoegen" + "value" : "Legg til" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Legg til" + "value" : "Toevoegen" } }, "pl" : { @@ -82078,16 +81199,16 @@ "value" : "RSS ソースを検出しました" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gedetecteerde RSS-bron" + "value" : "Oppdaget RSS kilde" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Oppdaget RSS kilde" + "value" : "Gedetecteerde RSS-bron" } }, "pl" : { @@ -82220,16 +81341,16 @@ "value" : "RssHub를 사용하려면 RssHub 호스트를 설정하거나 공개 RssHub 서버를 선택해야 합니다." } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "U moet de RssHub host instellen als u RssHub wilt gebruiken, of selecteer de publieke RssHub server" + "value" : "Du må angi RssHUB-vert hvis du vil bruke RsssHub, eller velge den offentlige RssHUB-serveren" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Du må angi RssHUB-vert hvis du vil bruke RsssHub, eller velge den offentlige RssHUB-serveren" + "value" : "U moet de RssHub host instellen als u RssHub wilt gebruiken, of selecteer de publieke RssHub server" } }, "pl" : { @@ -82362,16 +81483,16 @@ "value" : "ここにRssHubホストを入力してください" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voer hier de host van RssHub in" + "value" : "Vennligst skriv Rsshule-vert her" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vennligst skriv Rsshule-vert her" + "value" : "Voer hier de host van RssHub in" } }, "pl" : { @@ -82504,16 +81625,16 @@ "value" : "RssHub 호스트" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "RssHub host" + "value" : "RssHUB-vert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "RssHUB-vert" + "value" : "RssHub host" } }, "pl" : { @@ -82646,16 +81767,16 @@ "value" : "RSS ソース名" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "RSS bronnaam" + "value" : "RSS kilde navn" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "RSS kilde navn" + "value" : "RSS bronnaam" } }, "pl" : { @@ -82776,16 +81897,16 @@ "value" : "開く" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Openen in" + "value" : "Åpne i" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Åpne i" + "value" : "Openen in" } }, "pl" : { @@ -82942,13 +82063,13 @@ "value" : "앱" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "App" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "App" @@ -83096,16 +82217,16 @@ "value" : "ブラウザー" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "browser" + "value" : "Nettleser" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nettleser" + "value" : "browser" } }, "pl" : { @@ -83244,16 +82365,16 @@ "value" : "검색된 RSS 소스" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ontdekte Rss Bronnen" + "value" : "Oppdaget Rss kilder" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Oppdaget Rss kilder" + "value" : "Ontdekte Rss Bronnen" } }, "pl" : { @@ -83386,13 +82507,13 @@ "value" : "RSS" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "RSS" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "RSS" @@ -83516,13 +82637,13 @@ "value" : "RSS URL" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "RSS URL" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "RSS URL" @@ -83652,16 +82773,16 @@ "value" : "ここにURLを入力してください" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voer de URL hier in" + "value" : "Skriv inn URL'en her" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Skriv inn URL'en her" + "value" : "Voer de URL hier in" } }, "pl" : { @@ -83782,16 +82903,16 @@ "value" : "保存が完了しました" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opslaan voltooid" + "value" : "Lagring fullført" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lagring fullført" + "value" : "Opslaan voltooid" } }, "pl" : { @@ -83912,16 +83033,16 @@ "value" : "データの保存に失敗しました" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gegevens opslaan mislukt" + "value" : "Klarte ikke å lagre data" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Klarte ikke å lagre data" + "value" : "Gegevens opslaan mislukt" } }, "pl" : { @@ -84042,16 +83163,16 @@ "value" : "スクリーンショットを保存" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Schermafdruk opslaan" + "value" : "Lagre skjermbilde" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lagre skjermbilde" + "value" : "Schermafdruk opslaan" } }, "pl" : { @@ -84090,12 +83211,6 @@ "value" : "Spara skärmdump" } }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ekran görüntüsünü kaydet" - } - }, "uk" : { "stringUnit" : { "state" : "translated", @@ -84214,16 +83329,16 @@ "value" : "검색" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Zoeken" + "value" : "Søk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Søk" + "value" : "Zoeken" } }, "pl" : { @@ -84404,16 +83519,16 @@ "value" : "게시물" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Statuses" + "value" : "Innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg" + "value" : "Statuses" } }, "pl" : { @@ -84594,16 +83709,16 @@ "value" : "검색" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Zoeken" + "value" : "Søk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Søk" + "value" : "Zoeken" } }, "pl" : { @@ -84784,16 +83899,16 @@ "value" : "사용자" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gebruikers" + "value" : "Brukere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Brukere" + "value" : "Gebruikers" } }, "pl" : { @@ -84872,131 +83987,11 @@ }, "Select AI provider" : { "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "حدد موفر الذكاء الاصطناعي" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vyberte poskytovatele AI" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vælg AI-udbyder" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "KI-Anbieter auswählen" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Επιλέξτε πάροχο τεχνητής νοημοσύνης" - } - }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Select AI provider" } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Seleccionar proveedor AI" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Valitse tekoälyn tarjoaja" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sélectionnez le fournisseur d'IA" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Seleziona provider AI" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "AIプロバイダを選択" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Selecteer AI provider" - } - }, - "no" : { - "stringUnit" : { - "state" : "translated", - "value" : "Velg AI-leverandør" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wybierz dostawcę SI" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Selecione o provedor de IA" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Selecione o provedor de IA" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Selectați furnizorul AI" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Выберите поставщика AI" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Välj AI-leverantör" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Виберіть постачальника ШІ" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "选择 AI 提供商" - } } } }, @@ -85068,16 +84063,16 @@ "value" : "アイコンを選択" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Selecteer pictogram" + "value" : "Velg ikon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Velg ikon" + "value" : "Selecteer pictogram" } }, "pl" : { @@ -85234,16 +84229,16 @@ "value" : "메시지 보내기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verstuur bericht" + "value" : "Send melding" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Send melding" + "value" : "Verstuur bericht" } }, "pl" : { @@ -85395,16 +84390,16 @@ "value" : "表示" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Weergeven" + "value" : "Vis" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis" + "value" : "Weergeven" } }, "pl" : { @@ -85459,191 +84454,11 @@ }, "Server URL" : { "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bediener URL" - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "رابط الخادم" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Адрес на сървъра" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "URL del servidor" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "URL serveru" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Server URL" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Server-URL" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Url Διακομιστή" - } - }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Server URL" } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "URL del servidor" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Palvelimen URL" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "URL du serveur" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "כתובת URL של השרת" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Szerver URL" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "URL del server" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "サーバー URL" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "서버 URL" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Server URL" - } - }, - "no" : { - "stringUnit" : { - "state" : "translated", - "value" : "URL til server" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Adres URL serwera" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "URL do servidor" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "URL do servidor" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "URL Server" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "URL сервера" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "URL сервера" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Server URL" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sunucu URL'si" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "URL-адреса сервера" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "URL máy chủ" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "服务器 URL" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "伺服器網址" - } } } }, @@ -85715,16 +84530,16 @@ "value" : "RSS ソースを管理" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Uw RSS-bronnen beheren" + "value" : "Rediger RSS-kildene dine" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger RSS-kildene dine" + "value" : "Uw RSS-bronnen beheren" } }, "pl" : { @@ -85845,16 +84660,16 @@ "value" : "RSS管理" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "RSS Management" + "value" : "RSS administrasjon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "RSS administrasjon" + "value" : "RSS Management" } }, "pl" : { @@ -85893,12 +84708,6 @@ "value" : "RSS-hantering" } }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "RSS Yönetimi" - } - }, "uk" : { "stringUnit" : { "state" : "translated", @@ -85981,16 +84790,16 @@ "value" : "データをエクスポート" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gegevens exporteren" + "value" : "Eksporter data" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Eksporter data" + "value" : "Gegevens exporteren" } }, "pl" : { @@ -86111,16 +84920,16 @@ "value" : "データベースと設定を含むすべてのデータをエクスポート" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Exporteer alle gegevens inclusief database en instellingen" + "value" : "Eksporter alle data inkludert database og innstillinger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Eksporter alle data inkludert database og innstillinger" + "value" : "Exporteer alle gegevens inclusief database en instellingen" } }, "pl" : { @@ -86241,16 +85050,16 @@ "value" : "データのインポート" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gegevens importeren" + "value" : "Importer data" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Importer data" + "value" : "Gegevens importeren" } }, "pl" : { @@ -86371,16 +85180,16 @@ "value" : "ファイルからデータをインポート (既存のデータとマージ、重複を置き換えます)" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gegevens importeren uit bestand (samengevoegd met bestaande gegevens, vervangt duplicaten)" + "value" : "Importer data fra fil (sammenslåing med eksisterende data, erstatter duplikater)" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Importer data fra fil (sammenslåing med eksisterende data, erstatter duplikater)" + "value" : "Gegevens importeren uit bestand (samengevoegd met bestaande gegevens, vervangt duplicaten)" } }, "pl" : { @@ -86537,16 +85346,16 @@ "value" : "설정" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Instellingen" + "value" : "Innstillinger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innstillinger" + "value" : "Instellingen" } }, "pl" : { @@ -86727,16 +85536,16 @@ "value" : "공유" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Delen" + "value" : "Del" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Del" + "value" : "Delen" } }, "pl" : { @@ -86881,16 +85690,16 @@ "value" : "リンクを共有" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Link delen" + "value" : "Del kobling" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Del kobling" + "value" : "Link delen" } }, "pl" : { @@ -86929,12 +85738,6 @@ "value" : "Dela länk" } }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bağlantıyı paylaş" - } - }, "uk" : { "stringUnit" : { "state" : "translated", @@ -87017,16 +85820,16 @@ "value" : "スクリーンショットを共有" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Schermafdruk delen" + "value" : "Del skjermbilde" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Del skjermbilde" + "value" : "Schermafdruk delen" } }, "pl" : { @@ -87065,12 +85868,6 @@ "value" : "Dela skärmdump" } }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ekran görüntüsünü paylaş" - } - }, "uk" : { "stringUnit" : { "state" : "translated", @@ -87153,16 +85950,16 @@ "value" : "Fixvxでシェア" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Delen via Fixvx" + "value" : "Del via Fixvx" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Del via Fixvx" + "value" : "Delen via Fixvx" } }, "pl" : { @@ -87289,16 +86086,16 @@ "value" : "FxEmbedで共有" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Delen via FxEmbed" + "value" : "Del via FxEmbed" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Del via FxEmbed" + "value" : "Delen via FxEmbed" } }, "pl" : { @@ -87456,16 +86253,16 @@ "value" : "미디어 표시" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Media tonen" + "value" : "Vis media" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis media" + "value" : "Media tonen" } }, "pl" : { @@ -87628,16 +86425,16 @@ "value" : "소셜" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Sociaal" + "value" : "Sosial" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sosial" + "value" : "Sociaal" } }, "pl" : { @@ -87770,16 +86567,16 @@ "value" : "ステータス詳細" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Status detail" + "value" : "Post detaljer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Post detaljer" + "value" : "Status detail" } }, "pl" : { @@ -87936,16 +86733,16 @@ "value" : "공유" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Delen" + "value" : "Del" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Del" + "value" : "Delen" } }, "pl" : { @@ -88096,16 +86893,16 @@ "value" : "サマリー投稿" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Samenvatting bericht" + "value" : "Sammendrag post" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sammendrag post" + "value" : "Samenvatting bericht" } }, "pl" : { @@ -88232,16 +87029,16 @@ "value" : "投稿を翻訳" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bericht vertalen" + "value" : "Oversett innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Oversett innlegg" + "value" : "Bericht vertalen" } }, "pl" : { @@ -88398,16 +87195,16 @@ "value" : "팔로워" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgers" + "value" : "Følgere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følgere" + "value" : "Volgers" } }, "pl" : { @@ -88552,16 +87349,16 @@ "value" : "フォロワーだけがこの投稿を見ることができます" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Alleen volgers kunnen dit bericht zien" + "value" : "Bare tilhengere kan se denne posten" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Bare tilhengere kan se denne posten" + "value" : "Alleen volgers kunnen dit bericht zien" } }, "pl" : { @@ -88718,16 +87515,16 @@ "value" : "홈" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Startpagina" + "value" : "Hjem" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hjem" + "value" : "Startpagina" } }, "pl" : { @@ -88872,16 +87669,16 @@ "value" : "このインスタンス上のユーザーのみがこの投稿を見ることができます" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Alleen gebruikers op dit exemplaar kunnen dit bericht zien" + "value" : "Bare brukere i denne forekomsten kan se dette innlegget" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Bare brukere i denne forekomsten kan se dette innlegget" + "value" : "Alleen gebruikers op dit exemplaar kunnen dit bericht zien" } }, "pl" : { @@ -89038,16 +87835,16 @@ "value" : "공개" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Openbaar" + "value" : "Offentlig" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Offentlig" + "value" : "Openbaar" } }, "pl" : { @@ -89192,16 +87989,16 @@ "value" : "誰でもこの投稿を見ることができます。" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Iedereen kan dit bericht zien en opnieuw plaatsen" + "value" : "Alle kan se og poste dette innlegget" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Alle kan se og poste dette innlegget" + "value" : "Iedereen kan dit bericht zien en opnieuw plaatsen" } }, "pl" : { @@ -89358,16 +88155,16 @@ "value" : "명시된 사람만" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opgegeven" + "value" : "Spesifisert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Spesifisert" + "value" : "Opgegeven" } }, "pl" : { @@ -89512,16 +88309,16 @@ "value" : "メンションされたユーザーのみがこの投稿を見ることができます" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Alleen vermelde gebruikers kunnen dit bericht zien" + "value" : "Kun nevnte brukere kan se dette innlegget" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kun nevnte brukere kan se dette innlegget" + "value" : "Alleen vermelde gebruikers kunnen dit bericht zien" } }, "pl" : { @@ -89642,16 +88439,16 @@ "value" : "確認する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bevestigen" + "value" : "Slett databasemellomlager?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett databasemellomlager?" + "value" : "Bevestigen" } }, "pl" : { @@ -89772,16 +88569,16 @@ "value" : "%1$lld ユーザーと %2$lld ステータスが削除されます" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "%1$lld gebruikers en %2$lld statussen worden verwijderd" + "value" : "Slett database cache, %1$lld brukere og %2$lld innlegg vil bli slettet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett database cache, %1$lld brukere og %2$lld innlegg vil bli slettet" + "value" : "%1$lld gebruikers en %2$lld statussen worden verwijderd" } }, "pl" : { @@ -89938,16 +88735,16 @@ "value" : "이미지 캐시 지우기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Afbeeldingencache legen" + "value" : "Tøm bildebuffer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tøm bildebuffer" + "value" : "Afbeeldingencache legen" } }, "pl" : { @@ -90092,16 +88889,16 @@ "value" : "確認する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bevestigen" + "value" : "Slett bilde-hurtiglager?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett bilde-hurtiglager?" + "value" : "Bevestigen" } }, "pl" : { @@ -90258,16 +89055,16 @@ "value" : "Flare의 저장소 관리" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beheer Flare's opslag" + "value" : "Behandle flammetall lagring" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Behandle flammetall lagring" + "value" : "Beheer Flare's opslag" } }, "pl" : { @@ -90448,16 +89245,16 @@ "value" : "저장소" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opslagruimte" + "value" : "Lagring" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lagring" + "value" : "Opslagruimte" } }, "pl" : { @@ -90632,16 +89429,16 @@ "value" : "앱 로깅" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "App logboek" + "value" : "Logg app" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg app" + "value" : "App logboek" } }, "pl" : { @@ -90720,131 +89517,11 @@ }, "Summarize long text with AI" : { "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تلخيص النص الطويل مع الذكاء الاصطناعي" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Shrňte dlouhý text s AI" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Opsummér lang tekst med AI" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lange Text mit KI zusammenfassen" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Σύνοψη μεγάλου κειμένου με τεχνητή νοημοσύνη" - } - }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Summarize long text with AI" } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Resumir texto largo con IA" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tiivistä pitkä teksti tekoälyn avulla" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Résumer le texte long avec l'IA" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Riepilogo testo lungo con IA" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "AIで長いテキストをまとめます" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vat lange tekst samen met AI" - } - }, - "no" : { - "stringUnit" : { - "state" : "translated", - "value" : "oppsummerer lang tekst med AI" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Podsumowanie długiego tekstu z AI" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Resumir texto longo com IA" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Resumir texto longo com IA" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rezumă text lung cu AI" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Суммировать длинный текст с ИИ" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sammanfatta lång text med AI" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Підсумувати довгі тексти з ШІ" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "与 AI 摘要长文本" - } } } }, @@ -90916,16 +89593,16 @@ "value" : "この記事を要約" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Dit artikel samenvatten" + "value" : "oppsummerer denne artikkelen" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "oppsummerer denne artikkelen" + "value" : "Dit artikel samenvatten" } }, "pl" : { @@ -90980,131 +89657,11 @@ }, "Summary Prompt" : { "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "ملخص الطلب" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Souhrnný požadavek" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Resumé Spørg" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zusammenfassung-Aufforderung" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ερώτηση Περίληψης" - } - }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Summary Prompt" } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Resumen" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tiivistelmä" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Récapitulatif" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Prompt Del Riepilogo" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "概要のプロンプト表示" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Samenvatting Prompt" - } - }, - "no" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ledetekst i sammendrag" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pytanie podsumowujące" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Prompt de Resumo" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Prompt de Resumo" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sumar Prompt" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Краткая подсказка" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sammanfattning Fråga" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Підсумок" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "摘要提示" - } } } }, @@ -91206,16 +89763,16 @@ "value" : "시스템" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Systeem" + "value" : "Systemadministrasjon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Systemadministrasjon" + "value" : "Systeem" } }, "pl" : { @@ -91360,16 +89917,16 @@ "value" : "フレアの許可または言語を更新" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toestemming of taal van Vlam bijwerken" + "value" : "Oppdatere flammets tillatelse eller språk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Oppdatere flammets tillatelse eller språk" + "value" : "Toestemming of taal van Vlam bijwerken" } }, "pl" : { @@ -91490,16 +90047,16 @@ "value" : "システム設定" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Systeem instellingen" + "value" : "System innstillinger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "System innstillinger" + "value" : "Systeem instellingen" } }, "pl" : { @@ -91620,16 +90177,16 @@ "value" : "グループを追加" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Groep toevoegen" + "value" : "Legg til gruppe" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Legg til gruppe" + "value" : "Groep toevoegen" } }, "pl" : { @@ -91750,16 +90307,16 @@ "value" : "タブを追加" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Tabblad toevoegen" + "value" : "Legg til fane" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Legg til fane" + "value" : "Tabblad toevoegen" } }, "pl" : { @@ -91916,16 +90473,16 @@ "value" : "삭제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijderen" + "value" : "Slett" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett" + "value" : "Verwijderen" } }, "pl" : { @@ -92106,16 +90663,16 @@ "value" : "편집" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bewerken" + "value" : "Rediger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger" + "value" : "Bewerken" } }, "pl" : { @@ -92260,16 +90817,16 @@ "value" : "グループを編集" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Groep bewerken" + "value" : "Rediger gruppe" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger gruppe" + "value" : "Groep bewerken" } }, "pl" : { @@ -92390,13 +90947,13 @@ "value" : "Tab icon" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Tab icon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Tab icon" @@ -92526,16 +91083,16 @@ "value" : "タブのタイトル" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Tabblad titel" + "value" : "Tittel på fane" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tittel på fane" + "value" : "Tabblad titel" } }, "pl" : { @@ -92662,16 +91219,16 @@ "value" : "ここにタブのタイトルを入力してください" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voer de tab titel hier in" + "value" : "Skriv inn fanetittel her" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Skriv inn fanetittel her" + "value" : "Voer de tab titel hier in" } }, "pl" : { @@ -92792,16 +91349,16 @@ "value" : "ユーザーのアバターを表示" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toon gebruikersafbeelding" + "value" : "Vis brukerens profilbilde" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis brukerens profilbilde" + "value" : "Toon gebruikersafbeelding" } }, "pl" : { @@ -92958,16 +91515,16 @@ "value" : "혼합된 타임라인은 모든 탭의 타임라인 결과를 하나의 탭으로 혼합합니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gemengde tijdlijn mengt alle resultaten van de tabbladen in één tabblad" + "value" : "Blandet tidslinje vil blande alle faners tidslinjen resulterer i en fane" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Blandet tidslinje vil blande alle faners tidslinjen resulterer i en fane" + "value" : "Gemengde tijdlijn mengt alle resultaten van de tabbladen in één tabblad" } }, "pl" : { @@ -93112,16 +91669,16 @@ "value" : "混合タイムラインタブを有効にする" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Inschakelen gemengde tijdlijn tabblad" + "value" : "Aktiver blandet tidslinjefanel" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aktiver blandet tidslinjefanel" + "value" : "Inschakelen gemengde tijdlijn tabblad" } }, "pl" : { @@ -93242,16 +91799,16 @@ "value" : "グループ" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Groeperen" + "value" : "Gruppe" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Gruppe" + "value" : "Groeperen" } }, "pl" : { @@ -93372,16 +91929,16 @@ "value" : "このグループにはタブがありません" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Geen tabbladen in deze groep" + "value" : "Ingen faner i denne gruppen" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ingen faner i denne gruppen" + "value" : "Geen tabbladen in deze groep" } }, "pl" : { @@ -93502,16 +92059,16 @@ "value" : "グループ名" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Groep Naam" + "value" : "Gruppens navn" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Gruppens navn" + "value" : "Groep Naam" } }, "pl" : { @@ -93638,16 +92195,16 @@ "value" : "メインタブ" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Belangrijkste tabbladen" + "value" : "Hovedfaner" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hovedfaner" + "value" : "Belangrijkste tabbladen" } }, "pl" : { @@ -93804,16 +92361,16 @@ "value" : "탭 설정" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Tabblad instellingen" + "value" : "Fane innstillinger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Fane innstillinger" + "value" : "Tabblad instellingen" } }, "pl" : { @@ -93952,13 +92509,13 @@ "value" : "Tabs" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Tabs" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Tabs" @@ -94118,16 +92675,16 @@ "value" : "테마" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Thema" + "value" : "Tema" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tema" + "value" : "Thema" } }, "pl" : { @@ -94206,261 +92763,21 @@ }, "Translate Prompt" : { "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "ترجمة الطلب" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Přeložit výzvu" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Oversæt Spørg" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Übersetzungsaufforderung" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Μετάφραση Προώθησης" - } - }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Translate Prompt" } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Traducir Prompt" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Käännä Prompt" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Traduire l'invitation" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Traduci Prompt" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "翻訳プロンプトの翻訳" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Prompt vertalen" - } - }, - "no" : { - "stringUnit" : { - "state" : "translated", - "value" : "Oversett prompt" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Przetłumacz pytanie" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Traduzir Sugestão" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Traduzir Sugestão" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Traducere promptă" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Запрос на перевод" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Översätt prompt" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Перекласти підказку" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "翻译提示" - } } } }, "Translate text with AI" : { "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "ترجمة النص مع AI" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Přeložit text s AI" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Oversæt tekst med AI" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Text mit KI übersetzen" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Μετάφραση κειμένου με τεχνητή νοημοσύνη" - } - }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Translate text with AI" } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Traducir texto con IA" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Käännä teksti tekoälyllä" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Traduire du texte avec l'IA" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Traduci testo con IA" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "テキストをAIで翻訳" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vertaal tekst met AI" - } - }, - "no" : { - "stringUnit" : { - "state" : "translated", - "value" : "Oversett tekst med AI" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tłumacz tekst za pomocą AI" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Traduzir texto com IA" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Traduzir texto com IA" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tradu text cu AI" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Перевод текста с помощью ИИ" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Översätt text med AI" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Перекласти текст з ШІ" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "使用 AI 翻译文本" - } } } }, @@ -94568,16 +92885,16 @@ "value" : "차단 해제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Deblokkeer" + "value" : "Avblokker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Avblokker" + "value" : "Deblokkeer" } }, "pl" : { @@ -94758,16 +93075,16 @@ "value" : "좋아요 취소" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Anders dan" + "value" : "Ulikt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ulikt" + "value" : "Anders dan" } }, "pl" : { @@ -94942,16 +93259,16 @@ "value" : "음소거 해제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Deblokkeer" + "value" : "Udemp" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Udemp" + "value" : "Deblokkeer" } }, "pl" : { @@ -95132,16 +93449,16 @@ "value" : "팔로워" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volger" + "value" : "Følger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følger" + "value" : "Volger" } }, "pl" : { @@ -95322,16 +93639,16 @@ "value" : "팔로잉" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgt" + "value" : "Følger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følger" + "value" : "Volgt" } }, "pl" : { @@ -95409,6 +93726,7 @@ } }, "vvo_notification_like" : { + "extractionState" : "stale", "localizations" : { "af" : { "stringUnit" : { @@ -95512,16 +93830,16 @@ "value" : "당신의 상태에 좋아요를 눌렀습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vind je status leuk" + "value" : "Likte ditt innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Likte ditt innlegg" + "value" : "Vind je status leuk" } }, "pl" : { @@ -95702,16 +94020,16 @@ "value" : "댓글" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opmerkingen" + "value" : "Kommentarer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kommentarer" + "value" : "Opmerkingen" } }, "pl" : { @@ -95856,16 +94174,16 @@ "value" : "リポスト" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Herposten" + "value" : "Tilbakestilling" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tilbakestilling" + "value" : "Herposten" } }, "pl" : { @@ -96022,16 +94340,16 @@ "value" : "상태" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "status" + "value" : "Innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg" + "value" : "status" } }, "pl" : { @@ -96109,6 +94427,7 @@ } }, "xqt_notification_mention" : { + "extractionState" : "stale", "localizations" : { "af" : { "stringUnit" : { @@ -96212,16 +94531,16 @@ "value" : "당신을 언급했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "vermeldt je" + "value" : "nevner deg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "nevner deg" + "value" : "vermeldt je" } }, "pl" : { @@ -96299,6 +94618,7 @@ } }, "xqt_notification_retweet" : { + "extractionState" : "stale", "localizations" : { "af" : { "stringUnit" : { @@ -96396,16 +94716,16 @@ "value" : "리트윗했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "geretweet" + "value" : "retweeted" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "retweeted" + "value" : "geretweet" } }, "pl" : { diff --git a/iosApp/flare/UI/Component/CommonProfileHeader.swift b/iosApp/flare/UI/Component/CommonProfileHeader.swift index eb37856c1..2cae8d920 100644 --- a/iosApp/flare/UI/Component/CommonProfileHeader.swift +++ b/iosApp/flare/UI/Component/CommonProfileHeader.swift @@ -7,6 +7,38 @@ enum CommonProfileHeaderConstants { static let avatarSize: CGFloat = 96 } +private enum FollowButtonState: Equatable { + case blocked + case following + case requested + case follow + + init(_ relation: UiRelation) { + if relation.blocking { + self = .blocked + } else if relation.following { + self = .following + } else if relation.hasPendingFollowRequestFromYou { + self = .requested + } else { + self = .follow + } + } + + var titleKey: LocalizedStringKey { + switch self { + case .blocked: + "relation_blocked" + case .following: + "relation_following" + case .requested: + "relation_requested" + case .follow: + "relation_follow" + } + } +} + struct CommonProfileHeader: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.openURL) private var openURL @@ -53,37 +85,34 @@ struct CommonProfileHeader: View { VStack { Spacer() .frame(height: CommonProfileHeaderConstants.headerHeight) - if case .success(let data) = onEnum(of: isMe), !data.data.boolValue { - switch onEnum(of: relation) { - case .success(let relationState): - Button(action: { + if case .success(let data) = onEnum(of: isMe), !data.data.boolValue { + switch onEnum(of: relation) { + case .success(let relationState): + let buttonState = FollowButtonState(relationState.data) + VStack(spacing: 4) { + followButton(state: buttonState) { onFollowClick(relationState.data) - }, label: { - let text = if relationState.data.blocking { - String(localized: "relation_blocked") - } else if relationState.data.following { - String(localized: "relation_following") - } else if relationState.data.hasPendingFollowRequestFromYou { - String(localized: "relation_requested") - } else { - String(localized: "relation_follow") - } - Text(text) - }) - .backport - .glassProminentButtonStyle() + } + .id(buttonState) + .transition(.opacity.combined(with: .scale(scale: 0.92))) + if relationState.data.isFans { Text("relation_is_fans") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) } - case .loading: Button(action: {}, label: { - Text("#loading") - }) - .backport - .glassProminentButtonStyle() - .redacted(reason: .placeholder) - case .error: EmptyView() } + .animation(.spring(response: 0.25, dampingFraction: 0.86), value: buttonState) + case .loading: Button(action: {}, label: { + Text("#loading") + }) + .backport + .glassProminentButtonStyle() + .redacted(reason: .placeholder) + case .error: EmptyView() } + } } } @@ -99,6 +128,30 @@ struct CommonProfileHeader: View { .padding([.horizontal]) } } + + @ViewBuilder + private func followButton(state: FollowButtonState, action: @escaping () -> Void) -> some View { + switch state { + case .blocked: + Button(action: action) { + Text(state.titleKey) + } + .tint(.red) + .buttonStyle(.borderedProminent) + case .following, .requested: + Button(action: action) { + Text(state.titleKey) + } + .backport + .glassButtonStyle(fallbackStyle: .bordered) + case .follow: + Button(action: action) { + Text(state.titleKey) + } + .backport + .glassProminentButtonStyle() + } + } var content: some View { VStack( @@ -110,7 +163,7 @@ struct CommonProfileHeader: View { .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) HStack { - Text(user.handle) + Text(user.handle.canonical) .font(.subheadline) .foregroundColor(.gray) .textSelection(.enabled) diff --git a/iosApp/flare/UI/Component/ComposeTimelineView.swift b/iosApp/flare/UI/Component/ComposeTimelineView.swift index 0e4ffcd80..1845db41d 100644 --- a/iosApp/flare/UI/Component/ComposeTimelineView.swift +++ b/iosApp/flare/UI/Component/ComposeTimelineView.swift @@ -39,8 +39,8 @@ struct ComposeTimelineItemView : UIViewControllerRepresentable { struct ComposeTimelineView : UIViewControllerRepresentable { let key: String - let data: PagingState - let state: ComposeUIStateProxy> + let data: PagingState + let state: ComposeUIStateProxy> let topPadding: Int let onExpand: () -> Void let onCollapse: () -> Void @@ -48,7 +48,7 @@ struct ComposeTimelineView : UIViewControllerRepresentable { init( key: String, - data: PagingState, + data: PagingState, detailStatusKey: MicroBlogKey?, topPadding: Int, onOpenLink: @escaping (String) -> Void, @@ -63,7 +63,7 @@ struct ComposeTimelineView : UIViewControllerRepresentable { self.detailStatusKey = detailStatusKey if let state = ComposeUIStateProxyCache.shared.getOrCreate(key: key, factory: { .init(initialState: data, onOpenLink: onOpenLink) - }) as? ComposeUIStateProxy> { + }) as? ComposeUIStateProxy> { self.state = state } else { self.state = ComposeUIStateProxy(initialState: data, onOpenLink: onOpenLink) diff --git a/iosApp/flare/UI/Component/ListCardView.swift b/iosApp/flare/UI/Component/ListCardView.swift index 7b6c2a3f8..2c7c1ddd5 100644 --- a/iosApp/flare/UI/Component/ListCardView.swift +++ b/iosApp/flare/UI/Component/ListCardView.swift @@ -10,22 +10,31 @@ struct ListCardView: View { var body: some View { content() - .background(Color(.secondarySystemGroupedBackground)) - .clipShape( - !isMultipleColumn ? - .rect( - topLeadingRadius: index == 0 ? cornerRadius : 4, - bottomLeadingRadius: index == totalCount - 1 ? cornerRadius : 4, - bottomTrailingRadius: index == totalCount - 1 ? cornerRadius : 4, - topTrailingRadius: index == 0 ? cornerRadius : 4, - ) : - .rect( - topLeadingRadius: cornerRadius, - bottomLeadingRadius: cornerRadius, - bottomTrailingRadius: cornerRadius, - topTrailingRadius: cornerRadius, + .background { + UnevenRoundedRectangle( + cornerRadii: cornerRadii, + style: .continuous ) + .fill(Color(.secondarySystemGroupedBackground)) + } + } + + private var cornerRadii: RectangleCornerRadii { + if isMultipleColumn { + RectangleCornerRadii( + topLeading: cornerRadius, + bottomLeading: cornerRadius, + bottomTrailing: cornerRadius, + topTrailing: cornerRadius + ) + } else { + RectangleCornerRadii( + topLeading: index == 0 ? cornerRadius : 4, + bottomLeading: index == totalCount - 1 ? cornerRadius : 4, + bottomTrailing: index == totalCount - 1 ? cornerRadius : 4, + topTrailing: index == 0 ? cornerRadius : 4 ) + } } } diff --git a/iosApp/flare/UI/Component/RichText.swift b/iosApp/flare/UI/Component/RichText.swift index 6050fc7b4..e4ca993b7 100644 --- a/iosApp/flare/UI/Component/RichText.swift +++ b/iosApp/flare/UI/Component/RichText.swift @@ -1,46 +1,34 @@ import SwiftUI import KotlinSharedUI import Kingfisher -import SwiftUIBackports struct RichText: View { let text: UiRichText @State private var images: [String: Image] = [:] @ScaledMetric(relativeTo: .body) var imageSize = 17 @Environment(\.openURL) var openURL - - enum RichTextContent: Identifiable { - var id: String { - switch self { - case .text(let text): - return "text-\(text)" - case .blockImage(let url, let href): - return "img-\(url)-\(href ?? "")" - } - } - case text(Text) - case blockImage(url: String, href: String?) - } var body: some View { VStack(alignment: .leading, spacing: 8) { - ForEach(render(text: text, images: images)) { content in + ForEach(Array(contents.enumerated()), id: \.offset) { _, content in switch content { - case .text(let text): - text - case .blockImage(let url, let href): - if let url = URL(string: url) { + case let textContent as PlatformTextTextContent: + render(textContent: textContent) + case let imageContent as PlatformTextBlockImageContent: + if let url = URL(string: imageContent.url) { KFImage(url) .resizable() .scaledToFit() .clipShape(RoundedRectangle(cornerRadius: 12)) .contentShape(Rectangle()) .onTapGesture { - if let href, let link = URL(string: href) { + if let href = imageContent.href, let link = URL(string: href) { openURL(link) } } } + default: + EmptyView() } } } @@ -64,7 +52,7 @@ struct RichText: View { } } } - + for await (urlString, image) in group { if let image = image { images[urlString] = image @@ -74,157 +62,28 @@ struct RichText: View { } } - func render(text: UiRichText, images: [String: Image]) -> [RichTextContent] { - var contents: [RichTextContent] = [] - - // Context to maintain state during traversal - class RenderContext { - var currentText = Text("") - var attributedString = AttributedString() - var attributeContainer = AttributeContainer() - var isEmpty = true - var isBlockState = false - - func flush(to list: inout [RichTextContent]) { - if !isEmpty { - // Combine result+Text(attributedString) - // But `Text` doesn't expose concatenation easily outside of ViewBuilder or `+` operator on Text values - // We must commit attributedString to currentText first - commitAttributedString() - list.append(.text(currentText)) - currentText = Text("") - isEmpty = true - } - } - - func commitAttributedString() { - if attributedString.characters.count > 0 { - currentText = currentText + Text(attributedString) - attributedString = AttributedString() - } - } - - func appendText(_ text: Text) { - commitAttributedString() - currentText = currentText + text - isEmpty = false - } - } - - let context = RenderContext() - - func renderNode(_ node: KsoupNode) { - if let element = node as? KsoupElement { - renderElement(element) - } else if let textNode = node as? KsoupTextNode { - context.attributedString = context.attributedString + AttributedString(textNode.text(), attributes: context.attributeContainer) - // Text added implicitly makes it not empty, but we verify emptiness on flush by character count or flag - context.isEmpty = false - } - } - - func renderElement(_ element: KsoupElement) { - switch element.tagName().lowercased() { - case "a": - let href = element.attribute(key: "href")?.value ?? "" - let currentAttributes = context.attributeContainer - context.attributeContainer = AttributeContainer() - context.attributeContainer.link = URL(string: href) - element.childNodes().forEach { renderNode($0) } - context.attributeContainer = currentAttributes - case "strong", "b": - let currentAttributes = context.attributeContainer - context.attributeContainer = AttributeContainer() - context.attributeContainer.font = .system(size: UIFont.systemFontSize, weight: .bold) - element.childNodes().forEach { renderNode($0) } - context.attributeContainer = currentAttributes - case "em", "i": - let currentAttributes = context.attributeContainer - context.attributeContainer = AttributeContainer() - context.attributeContainer.font = .system(size: UIFont.systemFontSize, weight: .regular).italic() - element.childNodes().forEach { renderNode($0) } - context.attributeContainer = currentAttributes - case "br": - context.attributedString = context.attributedString + AttributedString("\n", attributes: context.attributeContainer) - context.isEmpty = false - case "p", "div": - element.childNodes().forEach { renderNode($0) } - if element.parent()?.childNodes().last != element { - context.attributedString = context.attributedString + AttributedString("\n\n", attributes: context.attributeContainer) - context.isEmpty = false - } - case "span": - element.childNodes().forEach { renderNode($0) } - case "del", "s": - let currentAttributes = context.attributeContainer - context.attributeContainer = AttributeContainer() - context.attributeContainer.strikethroughStyle = .single - element.childNodes().forEach { renderNode($0) } - context.attributeContainer = currentAttributes - case "code": - context.commitAttributedString() - let codeText = element.text() - let codeView = Text(codeText).font(.system(.body, design: .monospaced)) - context.appendText(codeView) - case "blockquote": - context.commitAttributedString() - let blockquoteText = element.text() - let quoteView = Text(blockquoteText).foregroundColor(.secondary).italic() - context.appendText(quoteView) - case "u": - let currentAttributes = context.attributeContainer - context.attributeContainer = AttributeContainer() - context.attributeContainer.underlineStyle = .single - element.childNodes().forEach { renderNode($0) } - context.attributeContainer = currentAttributes - case "small": - let currentAttributes = context.attributeContainer - context.attributeContainer = AttributeContainer() - context.attributeContainer.font = .system(size: UIFont.smallSystemFontSize) - element.childNodes().forEach { renderNode($0) } - context.attributeContainer = currentAttributes - case "emoji": - let src = element.attribute(key: "target")?.value ?? "" - if let image = images[src] { - context.commitAttributedString() - context.appendText(Text(image).baselineOffset(-3)) - } else { - let alt = element.attribute(key: "alt")?.value ?? "" - context.attributedString = context.attributedString + AttributedString(alt, attributes: context.attributeContainer) - context.isEmpty = false - } - case "figure": - context.isBlockState = true - element.childNodes().forEach { renderNode($0) } - context.isBlockState = false - case "img": - let src = element.attribute(key: "src")?.value ?? "" - if context.isBlockState { - // Block image: Flush text, add image - context.flush(to: &contents) - let href = element.attribute(key: "href")?.value - contents.append(.blockImage(url: src, href: href)) + private var contents: [PlatformTextContent] { + (text.platformText as? NSArray)?.compactMap { $0 as? PlatformTextContent } ?? [] + } + + private func render(textContent: PlatformTextTextContent) -> Text { + textContent.runs.reduce(Text("")) { partial, run in + switch run { + case let attributedRun as PlatformTextAttributedRun: + partial + Text(attributedRun.text) + case let imageRun as PlatformTextImageRun: + if let image = images[imageRun.url] { + partial + Text(image).baselineOffset(-3) } else { - // Inline image - if let image = images[src] { - context.commitAttributedString() - context.appendText(Text(image).baselineOffset(-3)) - } else { - let alt = element.attribute(key: "alt")?.value ?? "" - context.attributedString = context.attributedString + AttributedString(alt, attributes: context.attributeContainer) - context.isEmpty = false - } + partial + Text(imageRun.alt) } default: - element.childNodes().forEach { renderNode($0) } + partial } } - - renderNode(text.data) - context.flush(to: &contents) - return contents } } + extension UIImage { func resize(height: CGFloat) -> UIImage? { let heightRatio = height / size.height diff --git a/iosApp/flare/UI/Component/Status/FeedView.swift b/iosApp/flare/UI/Component/Status/FeedView.swift index 07b63bcf7..fcd72be98 100644 --- a/iosApp/flare/UI/Component/Status/FeedView.swift +++ b/iosApp/flare/UI/Component/Status/FeedView.swift @@ -3,22 +3,26 @@ import KotlinSharedUI struct FeedView: View { @Environment(\.openURL) private var openURL - let data: UiTimeline.ItemContentFeed + let data: UiTimelineV2.Feed // @State private var showDetail = false + private var descriptionText: String? { + let description = data.description_ ?? data.description + return description.isEmpty ? nil : description + } var body: some View { VStack( alignment: .leading ) { HStack { - if let sourceIcon = data.sourceIcon, !sourceIcon.isEmpty { + if let sourceIcon = data.source.icon, !sourceIcon.isEmpty { NetworkImage(data: sourceIcon) .frame(width: 20, height: 20) } - Text(data.source) + Text(data.source.name) .font(.footnote) .fixedSize(horizontal: false, vertical: true) Spacer() - if let date = data.createdAt { + if let date = data.actualCreatedAt { DateTimeText(data: date) .font(.footnote) .foregroundStyle(.secondary) @@ -28,7 +32,7 @@ struct FeedView: View { Text(title) } HStack { - if let desc = data.description_ { + if let desc = descriptionText { Text(desc) .font(.caption) .foregroundStyle(.secondary) @@ -36,8 +40,8 @@ struct FeedView: View { .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) } - if let image = data.image { - NetworkImage(data: image, customHeader: data.imageHeaders) + if let image = data.media { + NetworkImage(data: image.url, customHeader: image.customHeaders) .frame(width: 72, height: 72) .clipShape(RoundedRectangle(cornerRadius: 8)) } diff --git a/iosApp/flare/UI/Component/Status/StatusActionView.swift b/iosApp/flare/UI/Component/Status/StatusActionView.swift index 6d5d48e8b..f196da40a 100644 --- a/iosApp/flare/UI/Component/Status/StatusActionView.swift +++ b/iosApp/flare/UI/Component/Status/StatusActionView.swift @@ -94,43 +94,24 @@ struct StatusActionView: View { } .buttonStyle(.plain) } - case .asyncActionMenuItem(let asyncItem): - EmptyView() -// AsyncStatusActionView(data: asyncItem) case .divider: Divider() } } } -struct AsyncStatusActionView: View { - let data: ActionMenuAsyncActionMenuItem - var body: some View { - // TODO: Not supported yet -// Button { -// -// } label: { -// -// } -// .onAppear { -// } - } -} - struct StatusActionItemView: View { @Environment(\.appearanceSettings.showNumbers) private var showNumbers @Environment(\.openURL) private var openURL @ScaledMetric(relativeTo: .footnote) var fontSize = 13 - let data: ActionMenuItem + let data: ActionMenu.Item let useText: Bool let isFixedWidth: Bool var body: some View { Button( role: data.color?.role ) { - if let onClicked = data.onClicked { - onClicked(ClickContext(launcher: AppleUriLauncher(openUrl: openURL))) - } + data.onClicked(ClickContext(launcher: AppleUriLauncher(openUrl: openURL))) } label: { if useText, let text = data.text?.resolvedString { Label { @@ -182,7 +163,7 @@ struct StatusActionItemView: View { } } -extension ActionMenuItem.Color { +extension ActionMenu.ItemColor { var swiftColor: Color? { switch self { case .red: return .red @@ -239,22 +220,28 @@ extension ActionMenuItemText { case .unBlock: return "unblock" case .blockWithHandleParameter: return "block_user_with_handle \(localized.parameters.first ?? "")" case .muteWithHandleParameter: return "mute_user_with_handle \(localized.parameters.first ?? "")" + case .acceptFollowRequest: return "accept_follow_request" + case .rejectFollowRequest: return "reject_follow_request" } } } } struct StatusActionIcon: View { - let icon: ActionMenuItem.Icon? + let icon: UiIcon? var body: some View { if let icon = icon { - Image(icon.imageName) + icon.image } } } -extension ActionMenuItem.Icon { +extension UiIcon { + var image: Image { + Image(imageName) + } + var imageName: String { switch self { case .bookmark: return "fa-bookmark" @@ -263,7 +250,7 @@ extension ActionMenuItem.Icon { case .like: return "fa-heart" case .unlike: return "fa-heart.fill" case .more: return "fa-ellipsis" - case .quote: return "fa-quote-left" + case .quote: return "fa-reply" case .react: return "fa-plus" case .unReact: return "fa-minus" case .reply: return "fa-reply" @@ -279,6 +266,14 @@ extension ActionMenuItem.Icon { case .unMute: return "fa-volume-xmark" case .block: return "fa-user-slash" case .unBlock: return "fa-user-slash" + case .follow: return "fa-user-plus" + case .favourite: return "fa-heart" + case .mention: return "fa-at" + case .poll: return "fa-square-poll-horizontal" + case .edit: return "fa-pen" + case .info: return "fa-circle-info" + case .pin: return "fa-thumbtack" + case .check: return "fa-check" } } } diff --git a/iosApp/flare/UI/Component/Status/StatusPollView.swift b/iosApp/flare/UI/Component/Status/StatusPollView.swift index bad90f048..870524705 100644 --- a/iosApp/flare/UI/Component/Status/StatusPollView.swift +++ b/iosApp/flare/UI/Component/Status/StatusPollView.swift @@ -3,6 +3,7 @@ import KotlinSharedUI import SwiftUIBackports struct StatusPollView: View { + @Environment(\.openURL) private var openURL let data: UiPoll @State private var selectedOption: [Int] = [] var body: some View { @@ -82,7 +83,10 @@ struct StatusPollView: View { if data.canVote { Button { - data.onVote(selectedOption.map { KotlinInt(value: Int32($0)) } ) + data.onVote( + ClickContext(launcher: AppleUriLauncher(openUrl: openURL)), + selectedOption.map { KotlinInt(value: Int32($0)) } + ) } label: { Text("poll_vote") } @@ -92,4 +96,3 @@ struct StatusPollView: View { } } } - diff --git a/iosApp/flare/UI/Component/Status/StatusReactionView.swift b/iosApp/flare/UI/Component/Status/StatusReactionView.swift index 851a2961b..ded1213b9 100644 --- a/iosApp/flare/UI/Component/Status/StatusReactionView.swift +++ b/iosApp/flare/UI/Component/Status/StatusReactionView.swift @@ -2,13 +2,14 @@ import SwiftUI import KotlinSharedUI struct StatusReactionView: View { - let data: UiTimeline.ItemContentStatusBottomContentReaction + @Environment(\.openURL) private var openURL + let data: [UiTimelineV2.PostEmojiReaction] var body: some View { ScrollView(.horizontal) { LazyHStack { - ForEach(data.emojiReactions, id: \.name) { item in + ForEach(data, id: \.name) { item in Button { - item.onClicked() + item.onClicked(ClickContext(launcher: AppleUriLauncher(openUrl: openURL))) } label: { Label { Text(item.count.humanized) diff --git a/iosApp/flare/UI/Component/Status/StatusShareSheet.swift b/iosApp/flare/UI/Component/Status/StatusShareSheet.swift index 647fcaab1..acdd4bff4 100644 --- a/iosApp/flare/UI/Component/Status/StatusShareSheet.swift +++ b/iosApp/flare/UI/Component/Status/StatusShareSheet.swift @@ -117,7 +117,7 @@ struct StatusShareSheet: View { } @ViewBuilder - private func previewView(data: UiTimeline) -> some View { + private func previewView(data: UiTimelineV2) -> some View { TimelineView(data: data, detailStatusKey: statusKey, showTranslate: false) .frame(width: 360) .padding() @@ -132,11 +132,11 @@ struct StatusShareSheet: View { } - private func renderImage(data: UiTimeline) async -> UIImage? { + private func renderImage(data: UiTimelineV2) async -> UIImage? { return await previewView(data: data).snapshot() } - private func saveImage(data: UiTimeline) async { + private func saveImage(data: UiTimelineV2) async { guard let image = image else { return } UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) dismiss() diff --git a/iosApp/flare/UI/Component/Status/StatusTopMessageView.swift b/iosApp/flare/UI/Component/Status/StatusTopMessageView.swift index 1be2cf509..84319c947 100644 --- a/iosApp/flare/UI/Component/Status/StatusTopMessageView.swift +++ b/iosApp/flare/UI/Component/Status/StatusTopMessageView.swift @@ -3,15 +3,17 @@ import KotlinSharedUI struct StatusTopMessageView: View { @Environment(\.openURL) private var openURL - let topMessage: UiTimeline.TopMessage + let topMessage: UiTimelineV2.Message var body: some View { HStack { - topMessage.icon.awesomeImage + topMessage.icon.image if let user = topMessage.user { RichText(text: user.name) + .fixedSize(horizontal: false, vertical: true) } if let text = topMessage.type.localizedText { Text(text) + .fixedSize(horizontal: false, vertical: true) } } .onTapGesture { @@ -21,98 +23,150 @@ struct StatusTopMessageView: View { } } -extension UiTimeline.TopMessageIcon { - var awesomeImage: Image { - switch self { - case .retweet: - return Image("fa-retweet") - case .follow: - return Image("fa-user-plus") - case .favourite: - return Image("fa-heart") - case .mention: - return Image("fa-at") - case .poll: - return Image("fa-square-poll-horizontal") - case .edit: - return Image("fa-pen") - case .info: - return Image("fa-circle-info") - case .reply: - return Image("fa-reply") - case .quote: - return Image("fa-quote-left") - case .pin: - return Image("fa-thumbtack") - } - } -} - -extension UiTimeline.TopMessageMessageType { +extension UiTimelineV2.MessageType { var localizedText: String? { - switch onEnum(of: self) { - case .bluesky(let data): - switch onEnum(of: data) { - case .follow: String(localized: "bluesky_notification_follow") - case .like: String(localized: "bluesky_notification_like") - case .mention: String(localized: "bluesky_notification_mention") - case .quote: String(localized: "bluesky_notification_quote") - case .reply: String(localized: "bluesky_notification_reply") - case .repost: String(localized: "bluesky_notification_repost") - case .unKnown: String(localized: "bluesky_notification_unKnown") - case .starterpackJoined: String(localized: "bluesky_notification_starterpackJoined") - case .pinned: String(localized: "bluesky_notification_item_pin") - } - case .mastodon(let data): - switch onEnum(of: data) { - case .favourite: String(localized: "mastodon_notification_favourite") - case .follow: String(localized: "mastodon_notification_follow") - case .followRequest: String(localized: "mastodon_notification_follow_request") - case .mention: String(localized: "mastodon_notification_mention") - case .poll: String(localized: "mastodon_notification_poll") - case .reblogged: String(localized: "mastodon_notification_reblog") - case .status: String(localized: "mastodon_notification_status") - case .update: String(localized: "mastodon_notification_update") - case .pinned: String(localized: "mastodon_item_pinned") - case .unKnown: nil - } - case .misskey(let data): - switch onEnum(of: data) { - case .achievementEarned(let achive): - String( - format: NSLocalizedString("misskey_notification_achievement_earned", comment: ""), - String(localized: achive.achievement?.titleKey ?? ""), - String(localized: achive.achievement?.descriptionKey ?? "") - ) - case .app: String(localized: "misskey_notification_app") - case .follow: String(localized: "misskey_notification_follow") - case .followRequestAccepted: String(localized: "misskey_notification_follow_request_accepted") - case .mention: String(localized: "misskey_notification_mention") - case .pollEnded: String(localized: "misskey_notification_poll_ended") - case .quote: String(localized: "misskey_notification_quote") - case .reaction: String(localized: "misskey_notification_reaction") - case .receiveFollowRequest: String(localized: "misskey_notification_receive_follow_request") - case .renote: String(localized: "misskey_notification_renote") - case .reply: String(localized: "misskey_notification_reply") - case .pinned: String(localized: "mastodon_item_pinned") - case .unKnown(let type): String(format: NSLocalizedString("misskey_notification_unknwon", comment: ""), type.type) - } - case .vVO(let data): - switch onEnum(of: data) { - case .custom(let message): message.message - case .like: String(localized: "vvo_notification_like") - } - case .xQT(let data): - switch onEnum(of: data) { - case .custom(let message): message.message - case .mention: String(localized: "xqt_notification_mention") - case .retweet: String(localized: "xqt_notification_retweet") + if let data = self as? UiTimelineV2.MessageTypeRaw { + return data.content + } + if let data = self as? UiTimelineV2.MessageTypeUnknown { + return data.rawType.isEmpty ? nil : data.rawType + } + if let data = self as? UiTimelineV2.MessageTypeLocalized { + return switch data.data { + case .mention: + String(localized: "mastodon_notification_mention") + case .newPost: + String(localized: "mastodon_notification_status") + case .repost: + String(localized: "mastodon_notification_reblog") + case .follow: + String(localized: "mastodon_notification_follow") + case .followRequest: + String(localized: "mastodon_notification_follow_request") + case .favourite: + String(localized: "mastodon_notification_favourite") + case .pollEnded: + String(localized: "mastodon_notification_poll") + case .postUpdated: + String(localized: "mastodon_notification_update") + case .reply: + String(localized: "misskey_notification_reply") + case .quote: + String(localized: "misskey_notification_quote") + case .reaction: + String(localized: "misskey_notification_reaction") + case .followRequestAccepted: + String(localized: "misskey_notification_follow_request_accepted") + case .achievementEarned: + if let rawAchievement = data.args.first, + let achievement = MisskeyAchievement.from(rawAchievement) { + String( + localized: "misskey_notification_achievement_earned \(String(localized: achievement.titleKey)) \(String(localized: achievement.descriptionKey))" + ) + } else { + String( + format: NSLocalizedString("misskey_notification_achievement_earned", comment: ""), + data.args.first ?? "", + "" + ) + } + case .app: + String(localized: "misskey_notification_app") + case .starterpackJoined: + String(localized: "bluesky_notification_starterpackJoined") + case .pinned: + String(localized: "mastodon_item_pinned") } } + return nil } } extension MisskeyAchievement { + static func from(_ rawValue: String) -> MisskeyAchievement? { + switch rawValue { + case "notes1": return .notes1 + case "notes10": return .notes10 + case "notes100": return .notes100 + case "notes500": return .notes500 + case "notes1000": return .notes1000 + case "notes5000": return .notes5000 + case "notes10000": return .notes10000 + case "notes20000": return .notes20000 + case "notes30000": return .notes30000 + case "notes40000": return .notes40000 + case "notes50000": return .notes50000 + case "notes60000": return .notes60000 + case "notes70000": return .notes70000 + case "notes80000": return .notes80000 + case "notes90000": return .notes90000 + case "notes100000": return .notes100000 + case "login3": return .login3 + case "login7": return .login7 + case "login15": return .login15 + case "login30": return .login30 + case "login60": return .login60 + case "login100": return .login100 + case "login200": return .login200 + case "login300": return .login300 + case "login400": return .login400 + case "login500": return .login500 + case "login600": return .login600 + case "login700": return .login700 + case "login800": return .login800 + case "login900": return .login900 + case "login1000": return .login1000 + case "noteClipped1": return .noteClipped1 + case "noteFavorited1": return .noteFavorited1 + case "myNoteFavorited1": return .myNoteFavorited1 + case "profileFilled": return .profileFilled + case "markedAsCat": return .markedAsCat + case "following1": return .following1 + case "following10": return .following10 + case "following50": return .following50 + case "following100": return .following100 + case "following300": return .following300 + case "followers1": return .followers1 + case "followers10": return .followers10 + case "followers50": return .followers50 + case "followers100": return .followers100 + case "followers300": return .followers300 + case "followers500": return .followers500 + case "followers1000": return .followers1000 + case "collectAchievements30": return .collectAchievements30 + case "viewAchievements3Min": return .viewAchievements3Min + case "iLoveMisskey": return .iLoveMisskey + case "foundTreasure": return .foundTreasure + case "client30Min": return .client30Min + case "client60Min": return .client60Min + case "noteDeletedWithin1Min": return .noteDeletedWithin1Min + case "postedAtLateNight": return .postedAtLateNight + case "postedAt0Min0Sec": return .postedAt0Min0Sec + case "selfQuote": return .selfQuote + case "htl20Npm": return .htl20Npm + case "viewInstanceChart": return .viewInstanceChart + case "outputHelloWorldOnScratchpad": return .outputHelloWorldOnScratchpad + case "open3Windows": return .open3Windows + case "driveFolderCircularReference": return .driveFolderCircularReference + case "reactWithoutRead": return .reactWithoutRead + case "clickedClickHere": return .clickedClickHere + case "justPlainLucky": return .justPlainLucky + case "setNameToSyuilo": return .setNameToSyuilo + case "passedSinceAccountCreated1": return .passedSinceAccountCreated1 + case "passedSinceAccountCreated2": return .passedSinceAccountCreated2 + case "passedSinceAccountCreated3": return .passedSinceAccountCreated3 + case "loggedInOnBirthday": return .loggedInOnBirthday + case "loggedInOnNewYearsDay": return .loggedInOnNewYearsDay + case "cookieClicked": return .cookieClicked + case "brainDiver": return .brainDiver + case "smashTestNotificationButton": return .smashTestNotificationButton + case "tutorialCompleted": return .tutorialCompleted + case "bubbleGameExplodingHead": return .bubbleGameExplodingHead + case "bubbleGameDoubleExplodingHead": return .bubbleGameDoubleExplodingHead + default: return nil + } + } + var titleKey: LocalizedStringResource { switch self { case .notes1: return "misskey_achievement_notes1_title" diff --git a/iosApp/flare/UI/Component/Status/StatusView.swift b/iosApp/flare/UI/Component/Status/StatusView.swift index af8a9a69d..21032c2da 100644 --- a/iosApp/flare/UI/Component/Status/StatusView.swift +++ b/iosApp/flare/UI/Component/Status/StatusView.swift @@ -9,7 +9,7 @@ struct StatusView: View { @Environment(\.appearanceSettings.postActionStyle) private var postActionStyle @Environment(\.appearanceSettings.showPlatformLogo) private var showPlatformLogo @Environment(\.openURL) private var openURL - let data: UiTimeline.ItemContentStatus + let data: UiTimelineV2.Post var isDetail: Bool = false var isQuote: Bool = false var withLeadingPadding: Bool = false @@ -85,16 +85,13 @@ struct StatusView: View { alignment: .leading, spacing: 8, ) { - if let aboveTextContent = data.aboveTextContent { - switch onEnum(of: aboveTextContent) { - case .replyTo(let replyTo): - HStack { - Image("fa-reply") - Text("Reply to \(replyTo.handle)") - } - .font(.caption) - .foregroundStyle(.secondary) + if let replyToHandle = data.replyToHandle { + HStack { + Image("fa-reply") + Text("Reply to \(replyToHandle)") } + .font(.caption) + .foregroundStyle(.secondary) } if let contentWarning = data.contentWarning, !contentWarning.isEmpty { RichText(text: contentWarning) @@ -158,7 +155,25 @@ struct StatusView: View { if !data.images.isEmpty, showMedia { StatusMediaContent(data: data.images, sensitive: data.sensitive) { media, index in - data.onMediaClicked(ClickContext(launcher: AppleUriLauncher(openUrl: openURL)), media, .init(int: .init(index))) + 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 + } + let route = DeeplinkRoute.MediaStatusMedia( + statusKey: data.statusKey, + accountType: data.accountType, + index: Int32(index), + preview: preview + ) + if let url = URL(string: route.toUri()) { + openURL(url) + } } } @@ -187,8 +202,8 @@ struct StatusView: View { ) } - if case .reaction(let reaction) = onEnum(of: data.bottomContent), showMedia, !isQuote { - if let channel = reaction.channel { + if showMedia, !isQuote { + if let channel = data.sourceChannel { HStack { Image(.faTv) Text(channel.name) @@ -196,8 +211,8 @@ struct StatusView: View { .font(.footnote) .foregroundStyle(.secondary) } - if !reaction.emojiReactions.isEmpty { - StatusReactionView(data: reaction) + if !data.emojiReactions.isEmpty { + StatusReactionView(data: Array(data.emojiReactions)) } } @@ -234,13 +249,10 @@ struct StatusView: View { var topEndContent: some View { HStack { - switch onEnum(of: data.topEndContent) { - case .visibility(let visibility): - StatusVisibilityView(data: visibility.visibility) + if let visibility = data.visibility { + StatusVisibilityView(data: visibility) .font(.caption) .foregroundStyle(.secondary) - case .none: - EmptyView() } if showPlatformLogo { switch data.platformType { diff --git a/iosApp/flare/UI/Component/Status/StatusVisibilityView.swift b/iosApp/flare/UI/Component/Status/StatusVisibilityView.swift index 8d8653ccb..969451d24 100644 --- a/iosApp/flare/UI/Component/Status/StatusVisibilityView.swift +++ b/iosApp/flare/UI/Component/Status/StatusVisibilityView.swift @@ -2,7 +2,7 @@ import SwiftUI import KotlinSharedUI struct StatusVisibilityView: View { - let data: UiTimeline.ItemContentStatusTopEndContentVisibilityType + let data: UiTimelineV2.PostVisibility var body: some View { switch data { case .public: Image("fa-globe") diff --git a/iosApp/flare/UI/Component/Status/TimelineUserView.swift b/iosApp/flare/UI/Component/Status/TimelineUserView.swift index bde329634..179c4135d 100644 --- a/iosApp/flare/UI/Component/Status/TimelineUserView.swift +++ b/iosApp/flare/UI/Component/Status/TimelineUserView.swift @@ -3,7 +3,7 @@ import KotlinSharedUI struct TimelineUserView: View { @Environment(\.openURL) private var openURL - let data: UiTimeline.ItemContentUser + let data: UiTimelineV2.User var body: some View { VStack { UserCompatView(data: data.value) @@ -12,30 +12,8 @@ struct TimelineUserView: View { } if !data.button.isEmpty { HStack { - ForEach(0.. some View { + if let message { + StatusTopMessageView(topMessage: message) + .if(!fullWidthPost && !topMessageOnly, transform: { view in + view.padding(.leading, 44 - iconSize) + }) + .if(!topMessageOnly, transform: { view in + view.lineLimit(1) + .font(.caption) + .foregroundStyle(.secondary) + }) } -// .id(data.itemKey) } } extension TimelineView { - init(data: UiTimeline) { + init(data: UiTimelineV2) { self.data = data self.detailStatusKey = nil } diff --git a/iosApp/flare/UI/Component/Status/UserListView.swift b/iosApp/flare/UI/Component/Status/UserListView.swift index 5975fd157..635bdb22f 100644 --- a/iosApp/flare/UI/Component/Status/UserListView.swift +++ b/iosApp/flare/UI/Component/Status/UserListView.swift @@ -3,7 +3,7 @@ import KotlinSharedUI struct UserListView: View { @Environment(\.openURL) private var openURL - let data: UiTimeline.ItemContentUserList + let data: UiTimelineV2.UserList var body: some View { VStack { ScrollView(.horizontal) { @@ -24,7 +24,7 @@ struct UserListView: View { } } .scrollIndicators(.hidden) - if let status = data.status { + if let status = data.post { VStack { StatusView(data: status, isQuote: true, forceHideActions: true) .padding(8) diff --git a/iosApp/flare/UI/Component/TimelinePagingView.swift b/iosApp/flare/UI/Component/TimelinePagingView.swift index 1c9963a62..0c6f844f7 100644 --- a/iosApp/flare/UI/Component/TimelinePagingView.swift +++ b/iosApp/flare/UI/Component/TimelinePagingView.swift @@ -3,7 +3,7 @@ import WaterfallGrids import KotlinSharedUI struct TimelinePagingView: View { - let data: PagingState + let data: PagingState let detailStatusKey: MicroBlogKey? var body: some View { PagingView(data: data) { @@ -30,7 +30,7 @@ struct TimelinePagingView: View { } extension TimelinePagingView { - init(data: PagingState) { + init(data: PagingState) { self.data = data self.detailStatusKey = nil } @@ -38,12 +38,12 @@ extension TimelinePagingView { struct TimelineData: Identifiable, Hashable { let id: String - let data: UiTimeline? + let data: UiTimelineV2? let index: Int } struct TimelineCollection: @MainActor RandomAccessCollection { - let data: PagingStateSuccess + let data: PagingStateSuccess public var startIndex: Int { 0 } public var endIndex: Int { Int(data.itemCount) } @@ -64,7 +64,7 @@ struct TimelinePagingContent: View { @AppStorage("pref_timeline_use_compose_view") private var useComposeView: Bool = false @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.openURL) private var openURL - let data: PagingState + let data: PagingState let detailStatusKey: MicroBlogKey? let key: String var body: some View { @@ -125,7 +125,7 @@ struct TimelinePagingContent: View { } struct TimelineWaterFallPagingView: View { - let data: PagingState + let data: PagingState let detailStatusKey: MicroBlogKey? let columns: [WaterfallItems.Column] var body: some View { diff --git a/iosApp/flare/UI/Component/UserCompatView.swift b/iosApp/flare/UI/Component/UserCompatView.swift index 5b6531388..dfffba75c 100644 --- a/iosApp/flare/UI/Component/UserCompatView.swift +++ b/iosApp/flare/UI/Component/UserCompatView.swift @@ -20,7 +20,7 @@ struct UserCompatView: View { alignment: .leading ) { RichText(text: data.name) - Text(data.handle) + Text(data.handle.canonical) .font(.caption) .foregroundStyle(.secondary) } diff --git a/iosApp/flare/UI/Component/UserOnelineView.swift b/iosApp/flare/UI/Component/UserOnelineView.swift index edd015e11..9930def6c 100644 --- a/iosApp/flare/UI/Component/UserOnelineView.swift +++ b/iosApp/flare/UI/Component/UserOnelineView.swift @@ -21,7 +21,7 @@ struct UserOnelineView: View { } HStack { RichText(text: data.name) - Text(data.handle) + Text(data.handle.canonical) .font(.caption) .foregroundStyle(.secondary) } diff --git a/iosApp/flare/UI/Model/Extension.swift b/iosApp/flare/UI/Model/Extension.swift index f36e394e6..08160e082 100644 --- a/iosApp/flare/UI/Model/Extension.swift +++ b/iosApp/flare/UI/Model/Extension.swift @@ -1,13 +1,13 @@ import KotlinSharedUI -extension UiTimeline : Identifiable {} +extension UiTimelineV2 : Identifiable {} -extension UiTimeline.ItemContentFeed : Identifiable {} +extension UiTimelineV2.Feed : Identifiable {} -extension UiTimeline.ItemContentStatus : Identifiable {} +extension UiTimelineV2.Post : Identifiable {} -extension UiTimeline.ItemContentUser : Identifiable {} +extension UiTimelineV2.User : Identifiable {} -extension UiTimeline.ItemContentUserList : Identifiable {} +extension UiTimelineV2.UserList : Identifiable {} -extension UiTimeline.TopMessage : Identifiable {} +extension UiTimelineV2.Message : Identifiable {} diff --git a/iosApp/flare/UI/Route/Route.swift b/iosApp/flare/UI/Route/Route.swift index 5226d2bb4..fe513ee3b 100644 --- a/iosApp/flare/UI/Route/Route.swift +++ b/iosApp/flare/UI/Route/Route.swift @@ -148,7 +148,9 @@ enum Route: Hashable, Identifiable { case appLog case deepLinkAccountPicker(String, [MicroBlogKey: Route]) case blockUser(AccountType?, MicroBlogKey) + case unblockUser(AccountType?, MicroBlogKey) case muteUser(AccountType?, MicroBlogKey) + case unmuteUser(AccountType?, MicroBlogKey) case reportUser(AccountType?, MicroBlogKey) case editUserList(AccountType, MicroBlogKey) case userDirectMessages(AccountType, MicroBlogKey) @@ -252,6 +254,12 @@ enum Route: Hashable, Identifiable { } else { return .blockUser(nil, data.userKey) } + case .unblockUser(let data): + if let accountKey = data.accountKey { + return .unblockUser(.Specific(accountKey: accountKey), data.userKey) + } else { + return .unblockUser(nil, data.userKey) + } case .directMessage(let data): return .userDirectMessages(.Specific(accountKey: data.accountKey), data.userKey) case .editUserList(let data): @@ -262,6 +270,12 @@ enum Route: Hashable, Identifiable { } else { return .muteUser(nil, data.userKey) } + case .unmuteUser(let data): + if let accountKey = data.accountKey { + return .unmuteUser(.Specific(accountKey: accountKey), data.userKey) + } else { + return .unmuteUser(nil, data.userKey) + } case .reportUser(let data): if let accountKey = data.accountKey { return .reportUser(.Specific(accountKey: accountKey), data.userKey) @@ -283,8 +297,12 @@ enum Route: Hashable, Identifiable { return "mastodon_report_status_alert_title" case .blockUser: return "block_user_alert_title" + case .unblockUser: + return "unblock" case .muteUser: return "mute_user_alert_title" + case .unmuteUser: + return "unmute" default: return nil } @@ -299,8 +317,12 @@ enum Route: Hashable, Identifiable { Text("mastodon_report_status_alert_message") case .blockUser: Text("block_user_alert_message") + case .unblockUser: + Text("unblock") case .muteUser: Text("mute_user_alert_message") + case .unmuteUser: + Text("unmute") default: EmptyView() } @@ -334,14 +356,23 @@ enum Route: Hashable, Identifiable { Button("block", role: .destructive) { BlockUserPresenter(accountType: accountType, userKey: userKey).models.value.confirm() } + case .unblockUser(let accountType, let userKey): + Button("Cancel", role: .cancel) {} + Button("unblock", role: .destructive) { + UnblockUserPresenter(accountType: accountType, userKey: userKey).models.value.confirm() + } case .muteUser(let accountType, let userKey): Button("Cancel", role: .cancel) {} Button("mute", role: .destructive) { MuteUserPresenter(accountType: accountType, userKey: userKey).models.value.confirm() } + case .unmuteUser(let accountType, let userKey): + Button("Cancel", role: .cancel) {} + Button("unmute", role: .destructive) { + UnmuteUserPresenter(accountType: accountType, userKey: userKey).models.value.confirm() + } default: EmptyView() } } } - diff --git a/iosApp/flare/UI/Screen/ComposeScreen.swift b/iosApp/flare/UI/Screen/ComposeScreen.swift index 21e27e4c3..e6f44935d 100644 --- a/iosApp/flare/UI/Screen/ComposeScreen.swift +++ b/iosApp/flare/UI/Screen/ComposeScreen.swift @@ -19,68 +19,7 @@ struct ComposeScreen: View { VStack( spacing: 8 ) { - ScrollView(.horizontal) { - HStack { - StateView(state: presenter.state.selectedUsers) { users in - ForEach(0.. 0) { - Menu { - ForEach(0.. 0 { + Menu { + ForEach(0.. ComposeData.ReferenceStatus? { return if let data = composeStatus { - ComposeData.ReferenceStatus(data: presenter.state.replyState?.takeSuccess() as? UiTimeline, composeStatus: data) + ComposeData.ReferenceStatus(data: presenter.state.replyState?.takeSuccess() as? UiTimelineV2, composeStatus: data) } else { nil } @@ -484,10 +481,10 @@ struct ComposeScreen: View { nil } } - private func getVisibility() -> UiTimeline.ItemContentStatusTopEndContentVisibilityType { + private func getVisibility() -> UiTimelineV2.PostVisibility { switch onEnum(of: presenter.state.visibilityState) { - case .success(let success): return success.data.visibility - default: return .public + case .success(let success): return success.data.visibility + default: return .public } } @@ -734,7 +731,7 @@ struct ComposeMediaItemView: View { } } -extension UiTimeline.ItemContentStatusTopEndContentVisibilityType { +extension UiTimelineV2.PostVisibility { var title: LocalizedStringResource { switch self { case .public: diff --git a/iosApp/flare/UI/Screen/DMListScreen.swift b/iosApp/flare/UI/Screen/DMListScreen.swift index e43647dd7..918870791 100644 --- a/iosApp/flare/UI/Screen/DMListScreen.swift +++ b/iosApp/flare/UI/Screen/DMListScreen.swift @@ -26,7 +26,7 @@ struct DMListScreen: View { RichText(text: user.name) .lineLimit(1) if (item.users.count == 1) { - Text(user.handle) + Text(user.handle.canonical) .lineLimit(1) .font(.caption) .foregroundStyle(.secondary) diff --git a/iosApp/flare/UI/Screen/DiscoverScreen.swift b/iosApp/flare/UI/Screen/DiscoverScreen.swift index de9c5e34f..98d4e775f 100644 --- a/iosApp/flare/UI/Screen/DiscoverScreen.swift +++ b/iosApp/flare/UI/Screen/DiscoverScreen.swift @@ -62,7 +62,7 @@ struct DiscoverScreen: View { HStack { AvatarView(data: account.avatar) .frame(width: 18, height: 18) - Text(account.handle).font(.caption) + Text(account.handle.canonical).font(.caption) } .padding(.horizontal, 8) .padding(.vertical, 4) diff --git a/iosApp/flare/UI/Screen/NotificationScreen.swift b/iosApp/flare/UI/Screen/NotificationScreen.swift index 563809da5..1fbd687a8 100644 --- a/iosApp/flare/UI/Screen/NotificationScreen.swift +++ b/iosApp/flare/UI/Screen/NotificationScreen.swift @@ -100,20 +100,21 @@ struct NotificationFilterSegments: View { } struct NotificationAccountsBar: View { - let items: [UiProfile : KotlinInt] + let items: [NotificationAccountItem] let selectedAccount: UiProfile? let onSelect: (UiProfile) -> Void var body: some View { ScrollView(.horizontal) { HStack(spacing: 8) { - ForEach(Array(items.keys), id: \.handle) { key in - let value = items[key]?.intValue + ForEach(items, id: \.stableKey) { item in + let key = item.profile + let value = item.badge HStack { ZStack(alignment: .bottomTrailing) { AvatarView(data: key.avatar) - if let badge = value, badge > 0 { - Text("\(badge)") + if value > 0 { + Text("\(value)") .font(.caption2) .padding(2) .background( @@ -124,7 +125,7 @@ struct NotificationAccountsBar: View { .frame(width: 12, height: 12) } } - Text(key.handle) + Text(key.handle.canonical) } .onTapGesture { onSelect(key) diff --git a/iosApp/flare/UI/Screen/ProfileScreen.swift b/iosApp/flare/UI/Screen/ProfileScreen.swift index 57807c30c..890c4931f 100644 --- a/iosApp/flare/UI/Screen/ProfileScreen.swift +++ b/iosApp/flare/UI/Screen/ProfileScreen.swift @@ -4,6 +4,7 @@ import SwiftUIBackports struct ProfileScreen: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.openURL) private var openURL let accountType: AccountType let userKey: MicroBlogKey? let onFollowingClick: (MicroBlogKey) -> Void @@ -82,7 +83,7 @@ struct ProfileScreen: View { relation: presenter.state.relationState, isMe: presenter.state.isMe, onFollowClick: { user, relation in - presenter.state.follow(userKey: user.key, data: relation) + handleFollowAction(user: user, relation: relation) }, onFollowingClick: onFollowingClick, onFansClick: onFansClick @@ -114,7 +115,7 @@ struct ProfileScreen: View { relation: presenter.state.relationState, isMe: presenter.state.isMe, onFollowClick: { user, relation in - presenter.state.follow(userKey: user.key, data: relation) + handleFollowAction(user: user, relation: relation) }, onFollowingClick: onFollowingClick, onFansClick: onFansClick @@ -164,6 +165,21 @@ struct ProfileScreen: View { .listStyle(.plain) .edgesIgnoringSafeArea(.top) } + + private func handleFollowAction(user: UiProfile, relation: UiRelation) { + if relation.blocking { + if case .success(let state) = onEnum(of: presenter.state.myAccountKey) { + let route = DeeplinkRoute.UnblockUser(accountKey: state.data, userKey: user.key) + if let url = URL(string: route.toUri()) { + openURL(url) + } + } + } else if relation.following || relation.hasPendingFollowRequestFromYou { + presenter.state.unfollow(userKey: user.key) + } else { + presenter.state.follow(userKey: user.key) + } + } } extension ProfileScreen { diff --git a/iosApp/flare/UI/Screen/SearchScreen.swift b/iosApp/flare/UI/Screen/SearchScreen.swift index 1addb162e..716a67364 100644 --- a/iosApp/flare/UI/Screen/SearchScreen.swift +++ b/iosApp/flare/UI/Screen/SearchScreen.swift @@ -92,7 +92,7 @@ struct SearchScreen: View { HStack { AvatarView(data: account.avatar) .frame(width: 18, height: 18) - Text(account.handle).font(.caption) + Text(account.handle.canonical).font(.caption) } .padding(.horizontal, 8) .padding(.vertical, 4) diff --git a/iosApp/flare/UI/Screen/StatusDetailScreen.swift b/iosApp/flare/UI/Screen/StatusDetailScreen.swift index b51823808..1e6eb8305 100644 --- a/iosApp/flare/UI/Screen/StatusDetailScreen.swift +++ b/iosApp/flare/UI/Screen/StatusDetailScreen.swift @@ -9,7 +9,7 @@ struct StatusDetailScreen: View { private var detailStatusKey: MicroBlogKey? { return switch onEnum(of: presenter.state.current) { case .success(let data): - data.data.statusKey + (data.data as? UiTimelineV2.Post)?.statusKey default: nil } diff --git a/iosApp/flare/UI/Screen/StatusMediaScreen.swift b/iosApp/flare/UI/Screen/StatusMediaScreen.swift index 2ec03fc7e..c788f4c01 100644 --- a/iosApp/flare/UI/Screen/StatusMediaScreen.swift +++ b/iosApp/flare/UI/Screen/StatusMediaScreen.swift @@ -104,9 +104,11 @@ struct StatusMediaScreen: View { currentTime = .zero } .onChange(of: presenter.state.status) { oldValue, newValue in - if medias.isEmpty, case .success(let success) = onEnum(of: newValue), case .status(let content) = onEnum(of: success.data.content) { + if medias.isEmpty, + case .success(let success) = onEnum(of: newValue), + let content = success.data as? UiTimelineV2.Post { withAnimation { - medias = content.images + medias = Array(content.images) } } } @@ -152,7 +154,7 @@ struct StatusMediaScreen: View { } StateView(state: presenter.state.status) { timeline in - if case .status(let content) = onEnum(of: timeline.content) { + if let content = timeline as? UiTimelineV2.Post { StatusView(data: content, isQuote: true, showMedia: false, maxLine: 3, showExpandTextButton: false) } } diff --git a/iosApp/flare/UI/Screen/TabSettingsScreen.swift b/iosApp/flare/UI/Screen/TabSettingsScreen.swift index 4ead7798a..0b7ebea41 100644 --- a/iosApp/flare/UI/Screen/TabSettingsScreen.swift +++ b/iosApp/flare/UI/Screen/TabSettingsScreen.swift @@ -300,7 +300,7 @@ struct AddTabSheet: View { ForEach(0.. @Transaction - @Query("SELECT * FROM DbPagingTimeline WHERE pagingKey = :pagingKey AND accountType = :accountType LIMIT 1") + @Query( + "SELECT DbPagingTimeline.* FROM DbPagingTimeline " + + "INNER JOIN DbStatus ON DbStatus.statusKey = DbPagingTimeline.statusKey " + + "WHERE DbPagingTimeline.pagingKey = :pagingKey AND DbStatus.accountType = :accountType " + + "LIMIT 1", + ) fun get( pagingKey: String, accountType: DbAccountType, @@ -56,10 +64,26 @@ internal interface PagingTimelineDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(timeline: List) + @Query( + "SELECT * FROM DbPagingTimeline " + + "WHERE pagingKey = :pagingKey AND statusKey IN (:statusKeys)", + ) + suspend fun getByPagingKeyAndStatusKeys( + pagingKey: String, + statusKeys: List, + ): List + @Delete suspend fun delete(timeline: List) - @Query("DELETE FROM DbPagingTimeline WHERE pagingKey = :pagingKey AND accountType = :accountType") + @Query( + "DELETE FROM DbPagingTimeline WHERE pagingKey = :pagingKey " + + "AND EXISTS(" + + "SELECT 1 FROM DbStatus " + + "WHERE DbStatus.statusKey = DbPagingTimeline.statusKey " + + "AND DbStatus.accountType = :accountType" + + ")", + ) suspend fun delete( pagingKey: String, accountType: DbAccountType, @@ -78,24 +102,41 @@ internal interface PagingTimelineDao { @Query("DELETE FROM DbPagingTimeline WHERE pagingKey = :pagingKey") suspend fun delete(pagingKey: String) - @Query("DELETE FROM DbPagingTimeline WHERE accountType = :accountType") + @Query( + "DELETE FROM DbPagingTimeline " + + "WHERE EXISTS(" + + "SELECT 1 FROM DbStatus " + + "WHERE DbStatus.statusKey = DbPagingTimeline.statusKey " + + "AND DbStatus.accountType = :accountType" + + ")", + ) suspend fun deleteByAccountType(accountType: DbAccountType) - @Query("DELETE FROM DbPagingTimeline WHERE accountType = :accountType AND statusKey = :statusKey") + @Query( + "DELETE FROM DbPagingTimeline " + + "WHERE statusKey = :statusKey " + + "AND EXISTS(" + + "SELECT 1 FROM DbStatus " + + "WHERE DbStatus.statusKey = DbPagingTimeline.statusKey " + + "AND DbStatus.accountType = :accountType" + + ")", + ) suspend fun deleteStatus( accountType: DbAccountType, statusKey: MicroBlogKey, ) - suspend fun deleteStatus( - statusKey: MicroBlogKey, - accountKey: MicroBlogKey, - ) = deleteStatus( - accountType = AccountType.Specific(accountKey), - statusKey = statusKey, + @Query( + "SELECT EXISTS(" + + "SELECT 1 FROM DbPagingTimeline " + + "WHERE pagingKey = :paging_key " + + "AND EXISTS(" + + "SELECT 1 FROM DbStatus " + + "WHERE DbStatus.statusKey = DbPagingTimeline.statusKey " + + "AND DbStatus.accountType = :accountType" + + ")" + + ")", ) - - @Query("SELECT EXISTS(SELECT 1 FROM DbPagingTimeline WHERE accountType = :accountType AND pagingKey = :paging_key)") suspend fun existsPaging( accountType: DbAccountType, paging_key: String, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/StatusDao.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/StatusDao.kt index 0c12d5ccf..222a916df 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/StatusDao.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/StatusDao.kt @@ -4,10 +4,12 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import dev.dimension.flare.data.database.cache.model.DbStatus -import dev.dimension.flare.data.database.cache.model.StatusContent +import dev.dimension.flare.data.database.cache.model.DbStatusWithReference import dev.dimension.flare.model.DbAccountType import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 import kotlinx.coroutines.flow.Flow @Dao @@ -24,11 +26,24 @@ internal interface StatusDao { accountType: DbAccountType, ): Flow + @Transaction + @Query("SELECT * FROM DbStatus WHERE statusKey = :statusKey AND accountType = :accountType") + fun getWithReferences( + statusKey: MicroBlogKey, + accountType: DbAccountType, + ): Flow + + @Query("SELECT * FROM DbStatus WHERE accountType = :accountType AND statusKey IN (:statusKeys)") + suspend fun getByKeys( + statusKeys: List, + accountType: DbAccountType, + ): List + @Query("UPDATE DbStatus SET content = :content WHERE statusKey = :statusKey AND accountType = :accountType") suspend fun update( statusKey: MicroBlogKey, accountType: DbAccountType, - content: StatusContent, + content: UiTimelineV2, ) @Query("DELETE FROM DbStatus WHERE statusKey = :statusKey AND accountType = :accountType") diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/UserDao.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/UserDao.kt index 80e26879e..6262dbec4 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/UserDao.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/UserDao.kt @@ -9,10 +9,10 @@ import androidx.room.Transaction import dev.dimension.flare.data.database.cache.model.DbUser import dev.dimension.flare.data.database.cache.model.DbUserHistory import dev.dimension.flare.data.database.cache.model.DbUserHistoryWithUser -import dev.dimension.flare.data.database.cache.model.UserContent +import dev.dimension.flare.data.database.cache.model.DbUserRelation import dev.dimension.flare.model.DbAccountType import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.model.UiProfile import kotlinx.coroutines.flow.Flow @Dao @@ -26,7 +26,7 @@ internal interface UserDao { @Query("UPDATE DbUser SET content = :content WHERE userKey = :userKey") suspend fun update( userKey: MicroBlogKey, - content: UserContent, + content: UiProfile, ) @Query("SELECT * FROM DbUser WHERE userKey IN (:userKeys)") @@ -35,11 +35,10 @@ internal interface UserDao { @Query("SELECT * FROM DbUser WHERE userKey = :userKey") fun findByKey(userKey: MicroBlogKey): Flow - @Query("SELECT * FROM DbUser WHERE handle = :handle AND host = :host AND platformType = :platformType") - fun findByHandleAndHost( - handle: String, + @Query("SELECT * FROM DbUser WHERE canonicalHandle = :canonicalHandle AND host = :host") + fun findByCanonicalHandleAndHost( + canonicalHandle: String, host: String, - platformType: PlatformType, ): Flow @Query("SELECT COUNT(*) FROM DbUser") @@ -58,10 +57,28 @@ internal interface UserDao { @Transaction @Query( "SELECT * FROM DbUser " + - "WHERE DbUser.name like :query OR DbUser.handle like :query", + "WHERE DbUser.name like :query OR DbUser.canonicalHandle like :query", ) fun searchUser(query: String): PagingSource @Query("DELETE FROM DbUserHistory WHERE accountType = :accountType") suspend fun deleteHistoryByAccountType(accountType: DbAccountType) + + @Query("SELECT * FROM DbUserRelation WHERE accountType = :accountType AND userKey = :userKey") + fun getUserRelation( + accountType: DbAccountType, + userKey: MicroBlogKey, + ): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertUserRelation(relation: DbUserRelation) + + @Query("DELETE FROM DbUserRelation WHERE accountType = :accountType AND userKey = :userKey") + suspend fun deleteUserRelation( + accountType: DbAccountType, + userKey: MicroBlogKey, + ) + + @Query("DELETE FROM DbUserRelation") + suspend fun clearUserRelations() } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/Bluesky.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/Bluesky.kt index 8e7949853..95dcdf74b 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/Bluesky.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/Bluesky.kt @@ -1,19 +1,5 @@ package dev.dimension.flare.data.database.cache.mapper -import SnowflakeIdGenerator -import app.bsky.actor.ProfileView -import app.bsky.actor.ProfileViewBasic -import app.bsky.actor.ProfileViewDetailed -import app.bsky.bookmark.BookmarkView -import app.bsky.bookmark.BookmarkViewItemUnion -import app.bsky.feed.FeedViewPost -import app.bsky.feed.FeedViewPostReasonUnion -import app.bsky.feed.Like -import app.bsky.feed.PostView -import app.bsky.feed.ReplyRefParentUnion -import app.bsky.feed.Repost -import app.bsky.notification.ListNotificationsNotification -import app.bsky.notification.ListNotificationsNotificationReason import chat.bsky.convo.ConvoView import chat.bsky.convo.ConvoViewLastMessageUnion import chat.bsky.convo.MessageView @@ -22,24 +8,11 @@ import dev.dimension.flare.data.database.cache.model.DbDirectMessageTimeline import dev.dimension.flare.data.database.cache.model.DbMessageItem import dev.dimension.flare.data.database.cache.model.DbMessageRoom import dev.dimension.flare.data.database.cache.model.DbMessageRoomReference -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.database.cache.model.DbStatus -import dev.dimension.flare.data.database.cache.model.DbStatusWithUser -import dev.dimension.flare.data.database.cache.model.DbUser import dev.dimension.flare.data.database.cache.model.MessageContent -import dev.dimension.flare.data.database.cache.model.StatusContent -import dev.dimension.flare.data.database.cache.model.StatusContent.BlueskyNotification.Post -import dev.dimension.flare.data.database.cache.model.StatusContent.BlueskyNotification.UserList -import dev.dimension.flare.data.database.cache.model.UserContent import dev.dimension.flare.model.AccountType -import dev.dimension.flare.model.AccountType.Specific import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType -import dev.dimension.flare.model.ReferenceType -import dev.dimension.flare.ui.model.mapper.parseBlueskyJson -import kotlinx.collections.immutable.ImmutableMap -import kotlinx.coroutines.flow.firstOrNull -import sh.christian.ozone.api.AtUri +import dev.dimension.flare.ui.model.mapper.render internal object Bluesky { suspend fun saveDM( @@ -54,8 +27,13 @@ internal object Bluesky { it.lastMessage?.toDbMessageItem(it.toDbMessageRoom(accountKey.host).roomKey) } val timeline = data.map { it.toDbDirectMessageTimeline(accountKey) } - val users = data.flatMap { it.members }.map { it.toDbUser(accountKey.host) } - database.userDao().insertAll(users) + val users = + data + .flatMap { it.members } + .map { + it.render(accountKey).toDbUser(host = accountKey.host) + } + database.upsertUsers(users) database.messageDao().insertMessages(messages) database.messageDao().insertReferences(references) database.messageDao().insert(rooms) @@ -78,749 +56,8 @@ internal object Bluesky { database.messageDao().insertMessages(messages) // database.messageDao().insert(room) } - - suspend fun savePost( - accountKey: MicroBlogKey, - pagingKey: String, - database: CacheDatabase, - data: List, - sortIdProvider: (PostView) -> Long = { - it.indexedAt.toEpochMilliseconds() - }, - ) { - save(database, data.toDb(accountKey, pagingKey, sortIdProvider)) - } - - private suspend fun save( - database: CacheDatabase, - timeline: List, - ) { - ( - timeline.mapNotNull { it.status.status.user } + - timeline - .flatMap { it.status.references } - .mapNotNull { it.status?.user } - ).let { allUsers -> - val exsitingUsers = - database - .userDao() - .findByKeys(allUsers.map { it.userKey }) - .firstOrNull() - .orEmpty() - .filter { - it.content is UserContent.Bluesky - }.map { - val content = it.content as UserContent.Bluesky - val user = - allUsers.find { user -> - user.userKey == it.userKey - } - - if (user != null && user.content is UserContent.BlueskyLite) { - it.copy( - content = - content.copy( - data = - content.data.copy( - handle = user.content.data.handle, - displayName = user.content.data.displayName, - avatar = user.content.data.avatar, - ), - ), - ) - } else { - it - } - } - - val result = (exsitingUsers + allUsers).distinctBy { it.userKey } - database.userDao().insertAll(result) - } - ( - timeline.map { it.status.status.data } + - timeline - .flatMap { it.status.references } - .mapNotNull { it.status?.data } - ).let { - database.statusDao().insertAll(it) - } - timeline.flatMap { it.status.references }.map { it.reference }.let { - database.statusReferenceDao().delete(it.map { it.statusKey }) - database.statusReferenceDao().insertAll(it) - } - database.pagingTimelineDao().insertAll(timeline.map { it.timeline }) - } -} - -internal suspend fun List.toDb( - accountKey: MicroBlogKey, - pagingKey: String, - sortIdProvider: suspend (BookmarkView) -> Long = { - it.createdAt?.toEpochMilliseconds() ?: SnowflakeIdGenerator.nextId() - }, -): List = - this.mapNotNull { - it.toDbStatusWithUser(accountKey)?.let { status -> - createDbPagingTimelineWithStatus( - accountKey = accountKey, - pagingKey = pagingKey, - sortId = sortIdProvider(it), - status = status, - references = mapOf(), - ) - } - } - -internal fun List.toDb( - accountKey: MicroBlogKey, - pagingKey: String, - sortIdProvider: (PostView) -> Long = { it.indexedAt.toEpochMilliseconds() }, -): List = - this.map { - createDbPagingTimelineWithStatus( - accountKey = accountKey, - pagingKey = pagingKey, - sortId = sortIdProvider(it), - status = it.toDbStatusWithUser(accountKey), - references = mapOf(), - ) - } - -internal fun List.toDb( - accountKey: MicroBlogKey, - pagingKey: String, - references: ImmutableMap, -): List { - // merge same type - val grouped = this.groupBy { it.reason }.filter { it.value.any() } - return grouped.flatMap { (reason, items) -> - when (reason) { - is ListNotificationsNotificationReason.Unknown, - ListNotificationsNotificationReason.StarterpackJoined, - ListNotificationsNotificationReason.Verified, - ListNotificationsNotificationReason.Unverified, - -> - items.map { - createDbPagingTimelineWithStatus( - accountKey = accountKey, - pagingKey = pagingKey, - sortId = it.indexedAt.toEpochMilliseconds(), - status = it.toDbStatusWithUser(accountKey), - references = mapOf(), - ) - } - - ListNotificationsNotificationReason.Repost, ListNotificationsNotificationReason.Like -> { - val post = - items - .first() - .record - .let { - when (reason) { - ListNotificationsNotificationReason.Repost -> it.decodeAs().subject - ListNotificationsNotificationReason.Like -> it.decodeAs().subject - } - }.uri - .let { - references[it] - } - val content = - UserList( - data = items, - post = post, - ) - val idSuffix = - when (reason) { - ListNotificationsNotificationReason.Repost -> "_repost" - ListNotificationsNotificationReason.Like -> "_like" - } - val data = - DbStatusWithUser( - user = null, - data = - DbStatus( - statusKey = - MicroBlogKey( - id = items.joinToString("_") { it.uri.atUri } + idSuffix, - host = accountKey.host, - ), - accountType = Specific(accountKey), - userKey = null, - content = content, - text = null, - createdAt = items.first().indexedAt, - ), - ) - listOf( - createDbPagingTimelineWithStatus( - accountKey = accountKey, - pagingKey = pagingKey, - sortId = - items - .first() - .indexedAt - .toEpochMilliseconds(), - status = data, - references = - listOfNotNull( - post, - ).associate { - ReferenceType.Notification to - listOfNotNull( - it.toDbStatusWithUser( - accountKey = accountKey, - ), - ) - }, - ), - ) - } - - ListNotificationsNotificationReason.Follow -> { - val content = UserList(data = items, post = null) - val data = - DbStatusWithUser( - user = null, - data = - DbStatus( - statusKey = - MicroBlogKey( - id = items.joinToString("_") { it.uri.atUri } + "_follow", - host = accountKey.host, - ), - accountType = Specific(accountKey), - userKey = null, - content = content, - text = null, - createdAt = items.first().indexedAt, - ), - ) - listOfNotNull( - createDbPagingTimelineWithStatus( - accountKey = accountKey, - pagingKey = pagingKey, - sortId = - items - .first() - .indexedAt - .toEpochMilliseconds(), - status = data, - references = mapOf(), - ), - ) - } - - ListNotificationsNotificationReason.Mention, - ListNotificationsNotificationReason.Reply, - ListNotificationsNotificationReason.Quote, - -> { - items.mapNotNull { - val post = references[it.uri] ?: return@mapNotNull null - val content = Post(post = post) - val user = post.author.toDbUser(accountKey.host) - val data = - DbStatusWithUser( - user = user, - data = - DbStatus( - statusKey = - MicroBlogKey( - id = it.uri.atUri, - host = accountKey.host, - ), - accountType = Specific(accountKey), - userKey = user.userKey, - content = content, - text = null, - createdAt = it.indexedAt, - ), - ) - createDbPagingTimelineWithStatus( - accountKey = accountKey, - pagingKey = pagingKey, - sortId = it.indexedAt.toEpochMilliseconds(), - status = data, - references = - mapOf( - ReferenceType.Notification to - listOfNotNull( - post.toDbStatusWithUser( - accountKey, - ), - ), - ), - ) - } - } - - ListNotificationsNotificationReason.LikeViaRepost -> - items.mapNotNull { - val post = references[it.uri] ?: return@mapNotNull null - val content = Post(post = post) - val user = post.author.toDbUser(accountKey.host) - val data = - DbStatusWithUser( - user = user, - data = - DbStatus( - statusKey = - MicroBlogKey( - id = it.uri.atUri, - host = accountKey.host, - ), - accountType = Specific(accountKey), - userKey = user.userKey, - content = content, - text = null, - createdAt = it.indexedAt, - ), - ) - createDbPagingTimelineWithStatus( - accountKey = accountKey, - pagingKey = pagingKey, - sortId = it.indexedAt.toEpochMilliseconds(), - status = data, - references = - mapOf( - ReferenceType.Notification to - listOfNotNull( - post.toDbStatusWithUser( - accountKey, - ), - ), - ), - ) - } - - ListNotificationsNotificationReason.RepostViaRepost -> - items.mapNotNull { - val post = references[it.uri] ?: return@mapNotNull null - val content = Post(post = post) - val user = post.author.toDbUser(accountKey.host) - val data = - DbStatusWithUser( - user = user, - data = - DbStatus( - statusKey = - MicroBlogKey( - id = it.uri.atUri, - host = accountKey.host, - ), - accountType = Specific(accountKey), - userKey = user.userKey, - content = content, - text = null, - createdAt = it.indexedAt, - ), - ) - createDbPagingTimelineWithStatus( - accountKey = accountKey, - pagingKey = pagingKey, - sortId = it.indexedAt.toEpochMilliseconds(), - status = data, - references = - mapOf( - ReferenceType.Notification to - listOfNotNull( - post.toDbStatusWithUser( - accountKey, - ), - ), - ), - ) - } - - ListNotificationsNotificationReason.SubscribedPost -> { - items.mapNotNull { - val post = references[it.uri] ?: return@mapNotNull null - val content = Post(post = post) - val user = post.author.toDbUser(accountKey.host) - val data = - DbStatusWithUser( - user = user, - data = - DbStatus( - statusKey = - MicroBlogKey( - id = it.uri.atUri, - host = accountKey.host, - ), - accountType = Specific(accountKey), - userKey = user.userKey, - content = content, - text = null, - createdAt = it.indexedAt, - ), - ) - createDbPagingTimelineWithStatus( - accountKey = accountKey, - pagingKey = pagingKey, - sortId = it.indexedAt.toEpochMilliseconds(), - status = data, - references = - mapOf( - ReferenceType.Notification to - listOfNotNull( - post.toDbStatusWithUser( - accountKey, - ), - ), - ), - ) - } - } - - ListNotificationsNotificationReason.ContactMatch -> { - items.mapNotNull { - val post = references[it.uri] ?: return@mapNotNull null - val content = Post(post = post) - val user = post.author.toDbUser(accountKey.host) - val data = - DbStatusWithUser( - user = user, - data = - DbStatus( - statusKey = - MicroBlogKey( - id = it.uri.atUri, - host = accountKey.host, - ), - accountType = Specific(accountKey), - userKey = user.userKey, - content = content, - text = null, - createdAt = it.indexedAt, - ), - ) - createDbPagingTimelineWithStatus( - accountKey = accountKey, - pagingKey = pagingKey, - sortId = it.indexedAt.toEpochMilliseconds(), - status = data, - references = - mapOf( - ReferenceType.Notification to - listOfNotNull( - post.toDbStatusWithUser( - accountKey, - ), - ), - ), - ) - } - } - } - } -} - -private fun BookmarkView.toDbStatusWithUser(accountKey: MicroBlogKey): DbStatusWithUser? = - when (val content = item) { - is BookmarkViewItemUnion.BlockedPost -> null - is BookmarkViewItemUnion.NotFoundPost -> null - is BookmarkViewItemUnion.PostView -> content.value.toDbStatusWithUser(accountKey) - is BookmarkViewItemUnion.Unknown -> null - } - -private fun ListNotificationsNotification.toDbStatusWithUser(accountKey: MicroBlogKey): DbStatusWithUser { - val user = this.author.toDbUser(accountKey.host) - val status = this.toDbStatus(accountKey) - return DbStatusWithUser( - data = status, - user = user, - ) } -private fun ListNotificationsNotification.toDbStatus(accountKey: MicroBlogKey): DbStatus { - val user = this.author.toDbUser(accountKey.host) - return DbStatus( - statusKey = - MicroBlogKey( - uri.atUri + "_" + user.userKey, - accountKey.host, - ), - userKey = user.userKey, - content = StatusContent.BlueskyNotification.Normal(this), - accountType = AccountType.Specific(accountKey), - text = null, - createdAt = indexedAt, - ) -} - -internal suspend fun List.toDbPagingTimeline( - accountKey: MicroBlogKey, - pagingKey: String, - sortIdProvider: suspend (FeedViewPost) -> Long = { - when (val reason = it.reason) { -// is FeedViewPostReasonUnion.ReasonRepost -> { -// reason.value.indexedAt -// -// .toEpochMilliseconds() -// } - - is FeedViewPostReasonUnion.ReasonPin -> { - Long.MAX_VALUE - } - - else -> { - -SnowflakeIdGenerator.nextId() -// it.post.indexedAt -// -// .toEpochMilliseconds() - } - } - }, -): List { - // Build a map of all posts by URI for quick lookup - val postUriMap = mutableMapOf() - for (item in this) { - postUriMap[item.post.uri.atUri] = item - } - - // Identify which posts are parents of other posts in this feed - val parentUrisInFeed = mutableSetOf() - for (item in this) { - val parentUri = - when (val parent = item.reply?.parent) { - is ReplyRefParentUnion.PostView -> parent.value.uri.atUri - else -> null - } - if (parentUri != null) { - val inFeed = postUriMap.containsKey(parentUri) - if (inFeed) { - parentUrisInFeed.add(parentUri) - } - } - } - - // Build complete parent chains for each post - val parentChainMap = mutableMapOf>() - for (item in this) { - val parentChain = mutableListOf() - var currentPostUri: String? = item.post.uri.atUri - val visitedUris = mutableSetOf() // prevent infinite loops - - while (currentPostUri != null && !visitedUris.contains(currentPostUri) && parentChain.size < 1000) { - visitedUris.add(currentPostUri) - val currentFeedItem = postUriMap[currentPostUri] - if (currentFeedItem != null) { - val parentPostView = - when (val reply = currentFeedItem.reply?.parent) { - is ReplyRefParentUnion.PostView -> reply.value - else -> null - } - if (parentPostView != null) { - parentChain.add(parentPostView) - currentPostUri = parentPostView.uri.atUri - } else { - currentPostUri = null - } - } else { - currentPostUri = null - } - } - - if (parentChain.isNotEmpty()) { - parentChainMap[item.post.uri.atUri] = parentChain - } - } - - // Filter out posts that are parents of other posts, keeping only leaves/endpoints - val result = - this.flatMap { item -> - if (parentUrisInFeed.contains(item.post.uri.atUri)) { - emptyList() - } else { - processBlueskyFeedItem(item, accountKey, pagingKey, sortIdProvider, parentChainMap) - } - } - return result -} - -private suspend fun processBlueskyFeedItem( - it: FeedViewPost, - accountKey: MicroBlogKey, - pagingKey: String, - sortIdProvider: suspend (FeedViewPost) -> Long, - parentChainMap: Map>, -): List { - val parentChain = parentChainMap[it.post.uri.atUri].orEmpty() - - val status = - when (val data = it.reason) { - is FeedViewPostReasonUnion.ReasonRepost -> { - val user = data.value.by.toDbUser(accountKey.host) - DbStatusWithUser( - user = user, - data = - DbStatus( - statusKey = - MicroBlogKey( - it.post.uri.atUri + "_reblog_${user.userKey}", - accountKey.host, - ), - userKey = - data.value.by - .toDbUser(accountKey.host) - .userKey, - content = StatusContent.BlueskyReason(data), - accountType = AccountType.Specific(accountKey), - text = null, - createdAt = it.post.indexedAt, - ), - ) - } - - is FeedViewPostReasonUnion.ReasonPin -> { - val status = it.post.toDbStatusWithUser(accountKey) - DbStatusWithUser( - user = status.user, - data = - DbStatus( - statusKey = - MicroBlogKey( - it.post.uri.atUri + "_pin_${status.user?.userKey}", - accountKey.host, - ), - userKey = status.user?.userKey, - content = StatusContent.BlueskyReason(data), - accountType = AccountType.Specific(accountKey), - text = status.data.text, - createdAt = it.post.indexedAt, - ), - ) - } - - else -> { - // bluesky doesn't have "quote" and "retweet" as the same as the other platforms - it.post.toDbStatusWithUser(accountKey) - } - } - val references = - listOfNotNull( - if (parentChain.isNotEmpty()) { - val convertedParents = parentChain.mapNotNull { it.toDbStatusWithUser(accountKey) } - if (convertedParents.isNotEmpty()) { - ReferenceType.Reply to convertedParents - } else { - null - } - } else { - null - }, - if (it.reason != null) { - ReferenceType.Retweet to listOfNotNull(it.post.toDbStatusWithUser(accountKey)) - } else { - null - }, - ).toMap() - return listOfNotNull( - createDbPagingTimelineWithStatus( - accountKey = accountKey, - pagingKey = pagingKey, - sortId = sortIdProvider(it), - status = status, - references = references, - ), - ) -} - -private fun PostView.toDbStatusWithUser(accountKey: MicroBlogKey): DbStatusWithUser { - val user = author.toDbUser(accountKey.host) - val status = - DbStatus( - statusKey = - MicroBlogKey( - uri.atUri, - host = user.userKey.host, - ), - content = StatusContent.Bluesky(this), - userKey = user.userKey, - accountType = AccountType.Specific(accountKey), - text = parseBlueskyJson(record, accountKey).raw, - createdAt = indexedAt, - ) - return DbStatusWithUser( - data = status, - user = user, - ) -} - -internal fun ProfileView.toDbUser(host: String) = - DbUser( - userKey = - MicroBlogKey( - id = did.did, - host = host, - ), - platformType = PlatformType.Bluesky, - name = displayName.orEmpty(), - handle = handle.handle, - host = host, - content = - UserContent.BlueskyLite( - ProfileViewBasic( - did = did, - handle = handle, - displayName = displayName, - avatar = avatar, - ), - ), - ) - -internal fun ProfileViewBasic.toDbUser(host: String) = - DbUser( - userKey = - MicroBlogKey( - id = did.did, - host = host, - ), - platformType = PlatformType.Bluesky, - name = displayName.orEmpty(), - handle = handle.handle, - host = host, - content = UserContent.BlueskyLite(this), - ) - -internal fun chat.bsky.actor.ProfileViewBasic.toDbUser(host: String) = - DbUser( - userKey = - MicroBlogKey( - id = did.did, - host = host, - ), - platformType = PlatformType.Bluesky, - name = displayName.orEmpty(), - handle = handle.handle, - host = host, - content = - UserContent.BlueskyLite( - ProfileViewBasic( - did = did, - handle = handle, - displayName = displayName, - avatar = avatar, - associated = associated, - viewer = viewer, - labels = labels, - ), - ), - ) - -internal fun ProfileViewDetailed.toDbUser(host: String) = - DbUser( - userKey = - MicroBlogKey( - id = did.did, - host = host, - ), - platformType = PlatformType.Bluesky, - name = displayName.orEmpty(), - handle = handle.handle, - host = host, - content = UserContent.Bluesky(this), - ) - private fun ConvoView.toDbDirectMessageTimeline(accountKey: MicroBlogKey): DbDirectMessageTimeline { val roomKey = toDbMessageRoom(accountKey.host).roomKey return DbDirectMessageTimeline( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/Mastodon.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/Mastodon.kt deleted file mode 100644 index 9e16aa1ac..000000000 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/Mastodon.kt +++ /dev/null @@ -1,195 +0,0 @@ -package dev.dimension.flare.data.database.cache.mapper - -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.model.DbEmoji -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.database.cache.model.DbStatus -import dev.dimension.flare.data.database.cache.model.DbStatusWithUser -import dev.dimension.flare.data.database.cache.model.DbUser -import dev.dimension.flare.data.database.cache.model.StatusContent -import dev.dimension.flare.data.network.mastodon.api.model.Account -import dev.dimension.flare.data.network.mastodon.api.model.Emoji -import dev.dimension.flare.data.network.mastodon.api.model.Notification -import dev.dimension.flare.data.network.mastodon.api.model.Status -import dev.dimension.flare.model.AccountType -import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.model.PlatformType -import dev.dimension.flare.model.ReferenceType -import dev.dimension.flare.ui.model.mapper.parseMastodonContent -import dev.dimension.flare.ui.render.toUi -import kotlin.time.Clock - -internal object Mastodon { - suspend fun save( - accountKey: MicroBlogKey, - pagingKey: String, - database: CacheDatabase, - data: List, - sortIdProvider: (Status) -> Long = { - if (it.pinned == true) { - Long.MAX_VALUE - } else { - it.createdAt?.toEpochMilliseconds() ?: 0 - } - }, - ) { - val items = data.toDbPagingTimeline(accountKey, pagingKey, sortIdProvider) - saveToDatabase(database, items) - } - - suspend fun save( - accountKey: MicroBlogKey, - pagingKey: String, - database: CacheDatabase, - data: List, - ) { - val items = data.toDb(accountKey, pagingKey) - saveToDatabase(database, items) - } -} - -internal fun List.toDb( - accountKey: MicroBlogKey, - pagingKey: String, -): List = - this.map { - createDbPagingTimelineWithStatus( - accountKey = accountKey, - pagingKey = pagingKey, - sortId = it.createdAt?.toEpochMilliseconds() ?: 0, - status = it.toDbStatusWithUser(accountKey), - references = - listOfNotNull( - if (it.status != null) { - ReferenceType.Notification to listOfNotNull(it.status.toDbStatusWithUser(accountKey)) - } else { - null - }, - ).toMap(), - ) - } - -private fun Notification.toDbStatusWithUser(accountKey: MicroBlogKey): DbStatusWithUser { - val user = - this.account?.toDbUser(accountKey.host) ?: throw IllegalStateException("account is null") - val status = this.toDbStatus(accountKey) - return DbStatusWithUser( - data = status, - user = user, - ) -} - -private fun Notification.toDbStatus(accountKey: MicroBlogKey): DbStatus { - val user = - this.account?.toDbUser(accountKey.host) ?: throw IllegalStateException("account is null") - return DbStatus( - statusKey = - MicroBlogKey( - this.id ?: throw IllegalStateException("id is null"), - user.userKey.host, - ), - userKey = user.userKey, - content = StatusContent.MastodonNotification(this), - accountType = AccountType.Specific(accountKey), - text = null, - createdAt = createdAt ?: Clock.System.now(), - ) -} - -internal suspend fun List.toDbPagingTimeline( - accountKey: MicroBlogKey, - pagingKey: String, - sortIdProvider: suspend (Status) -> Long = { - it.createdAt?.toEpochMilliseconds() ?: 0 - }, -): List = - this.map { - createDbPagingTimelineWithStatus( - accountKey = accountKey, - pagingKey = pagingKey, - sortId = sortIdProvider(it), - status = it.toDbStatusWithUser(accountKey), - references = - listOfNotNull( - if (it.reblog != null) { - ReferenceType.Retweet to listOfNotNull(it.reblog.toDbStatusWithUser(accountKey)) - } else { - null - }, - ).toMap(), - ) - } - -private fun Status.toDbStatusWithUser(accountKey: MicroBlogKey): DbStatusWithUser { - val user = - account?.toDbUser(accountKey.host) - ?: throw IllegalArgumentException("mastodon Status.user should not be null") - val status = - DbStatus( - statusKey = - MicroBlogKey( - id - ?: throw IllegalArgumentException("mastodon Status.idStr should not be null"), - host = user.userKey.host, - ), - content = - dev.dimension.flare.data.database.cache.model.StatusContent - .Mastodon(this), - userKey = user.userKey, - accountType = AccountType.Specific(accountKey), - text = - buildString { - if (spoilerText != null) { - append(spoilerText) - append("\n\n") - } - append( - parseMastodonContent( - this@toDbStatusWithUser, - accountKey, - accountKey.host, - ).toUi().raw, - ) - }, - createdAt = createdAt ?: Clock.System.now(), - ) - return DbStatusWithUser( - data = status, - user = user, - ) -} - -internal fun Account.toDbUser(host: String): DbUser { - val remoteHost = - if (acct != null && acct.contains('@')) { - acct.substring(acct.indexOf('@') + 1) - } else { - host - } - return DbUser( - userKey = - MicroBlogKey( - id = id ?: throw IllegalArgumentException("mastodon Account.id should not be null"), - host = host, - ), - platformType = PlatformType.Mastodon, - name = - displayName - ?: throw IllegalArgumentException("mastodon Account.displayName should not be null"), - handle = - username - ?: throw IllegalArgumentException("mastodon Account.username should not be null"), - content = - dev.dimension.flare.data.database.cache.model.UserContent - .Mastodon(this), - host = remoteHost, - ) -} - -internal fun List.toDb(host: String): DbEmoji = - DbEmoji( - host = host, - content = - dev.dimension.flare.data.database.cache.model.EmojiContent - .Mastodon(this), - ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/Microblog.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/Microblog.kt index a02a646b7..1417329d0 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/Microblog.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/Microblog.kt @@ -3,155 +3,172 @@ package dev.dimension.flare.data.database.cache.mapper import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.model.DbPagingTimeline import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.database.cache.model.DbStatusReference -import dev.dimension.flare.data.database.cache.model.DbStatusReferenceWithStatus -import dev.dimension.flare.data.database.cache.model.DbStatusWithReference -import dev.dimension.flare.data.database.cache.model.DbStatusWithUser -import dev.dimension.flare.data.database.cache.model.UserContent -import dev.dimension.flare.model.AccountType +import dev.dimension.flare.data.database.cache.model.DbStatus import dev.dimension.flare.model.DbAccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.ReferenceType -import kotlinx.coroutines.flow.firstOrNull -import kotlin.uuid.Uuid +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.UiTimelineV2 +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList internal suspend fun saveToDatabase( database: CacheDatabase, items: List, ) { - ( - items.mapNotNull { it.status.status.user } + - items - .flatMap { it.status.references } - .mapNotNull { it.status?.user } - ).let { allUsers -> - val exsitingUsers = - database - .userDao() - .findByKeys(allUsers.map { it.userKey }) - .firstOrNull() - .orEmpty() - .map { - when (val content = it.content) { - is UserContent.Bluesky -> { - val user = - allUsers.find { user -> - user.userKey == it.userKey - } - if (user != null && user.content is UserContent.BlueskyLite) { - it.copy( - content = - content.copy( - data = - content.data.copy( - handle = user.content.data.handle, - displayName = user.content.data.displayName, - avatar = user.content.data.avatar, - ), - ), - ) - } else { - it - } - } - is UserContent.Misskey -> { - val user = - allUsers.find { user -> - user.userKey == it.userKey - } - if (user != null && user.content is UserContent.MisskeyLite) { - it.copy( - content = - content.copy( - data = - content.data.copy( - name = user.content.data.name, - username = user.content.data.username, - avatarUrl = user.content.data.avatarUrl, - ), - ), - ) - } else { - it - } - } - else -> it - } - } - - val result = (exsitingUsers + allUsers).distinctBy { it.userKey } - database.userDao().insertAll(result) - } - ( + val statuses = items.map { it.status.status.data } + items .flatMap { it.status.references } .mapNotNull { it.status?.data } - ).let { - database.statusDao().insertAll(it) + val users = statuses.flatMap { it.content.usersInContent() }.distinctBy { it.key } + database.upsertUsers(users.map { it.toDbUser() }) + val mergedStatuses = mergeWithExistingPostParents(database, statuses) + val changedStatuses = loadChangedStatuses(database, mergedStatuses) + if (changedStatuses.isNotEmpty()) { + database.statusDao().insertAll(changedStatuses) } items.flatMap { it.status.references }.map { it.reference }.let { // TODO: delete old references database.statusReferenceDao().insertAll(it) } - database.pagingTimelineDao().insertAll(items.map { it.timeline }) + val changedTimeline = loadChangedTimeline(database, items.map { it.timeline }) + if (changedTimeline.isNotEmpty()) { + database.pagingTimelineDao().insertAll(changedTimeline) + } } -internal fun createDbPagingTimelineWithStatus( - accountType: DbAccountType, - pagingKey: String, - sortId: Long, - status: DbStatusWithUser, - references: Map>, -): DbPagingTimelineWithStatus { - val timeline = - DbPagingTimeline( - accountType = accountType, - statusKey = status.data.statusKey, - pagingKey = pagingKey, - sortId = sortId, +private suspend fun mergeWithExistingPostParents( + database: CacheDatabase, + incoming: List, +): List { + if (incoming.isEmpty()) { + return incoming + } + + val candidatesByAccount = + incoming + .asSequence() + .mapNotNull { item -> + val post = item.content as? UiTimelineV2.Post ?: return@mapNotNull null + if (post.parents.isNotEmpty() || post.references.any { it.type == ReferenceType.Reply }) { + return@mapNotNull null + } + item.accountType to item.statusKey + }.groupBy( + keySelector = { it.first }, + valueTransform = { it.second }, + ) + if (candidatesByAccount.isEmpty()) { + return incoming + } + + val existingReplyReferencesByStatus = loadExistingPostReplyReferences(database, candidatesByAccount) + return incoming.map { item -> + val post = item.content as? UiTimelineV2.Post ?: return@map item + if (post.parents.isNotEmpty() || post.references.any { it.type == ReferenceType.Reply }) { + return@map item + } + val existingReplyReferences = existingReplyReferencesByStatus[item.accountType to item.statusKey] ?: return@map item + item.copy( + content = + post.copy( + references = + ( + post.references + + existingReplyReferences + ).distinctBy { it.type to it.statusKey } + .toImmutableList(), + ), ) - return DbPagingTimelineWithStatus( - timeline = timeline, - status = - DbStatusWithReference( - status = status, - references = - references.flatMap { (type, reference) -> - reference.map { - it.toDbStatusReference(status.data.statusKey, type) - } - }, - ), - ) + } } -internal fun createDbPagingTimelineWithStatus( - accountKey: MicroBlogKey, - pagingKey: String, - sortId: Long, - status: DbStatusWithUser, - references: Map>, -): DbPagingTimelineWithStatus = - createDbPagingTimelineWithStatus( - accountType = AccountType.Specific(accountKey), - pagingKey = pagingKey, - sortId = sortId, - status = status, - references = references, - ) +private suspend fun loadExistingPostReplyReferences( + database: CacheDatabase, + candidatesByAccount: Map>, +): Map, ImmutableList> { + val result = + mutableMapOf< + Pair, + ImmutableList, + >() + candidatesByAccount.forEach { (accountType, keys) -> + keys.distinct().chunked(SQL_IN_BATCH_SIZE).forEach { chunk -> + database.statusDao().getByKeys(statusKeys = chunk, accountType = accountType).forEach { existing -> + val existingPost = existing.content as? UiTimelineV2.Post ?: return@forEach + val replyReferences = + ( + existingPost.references.filter { it.type == ReferenceType.Reply } + + existingPost.parents.map { + UiTimelineV2.Post.Reference( + statusKey = it.statusKey, + type = ReferenceType.Reply, + ) + } + ).distinctBy { it.type to it.statusKey } + .toImmutableList() + if (replyReferences.isEmpty()) { + return@forEach + } + result[accountType to existing.statusKey] = replyReferences + } + } + } + return result +} + +private const val SQL_IN_BATCH_SIZE = 500 + +private suspend fun loadChangedStatuses( + database: CacheDatabase, + incoming: List, +): List { + val existingByKey = + incoming + .groupBy { it.accountType } + .flatMap { (accountType, accountStatuses) -> + accountStatuses + .map { it.statusKey } + .distinct() + .chunked(SQL_IN_BATCH_SIZE) + .flatMap { chunk -> + database.statusDao().getByKeys(statusKeys = chunk, accountType = accountType) + } + }.associateBy { it.id } + return incoming.filter { status -> + existingByKey[status.id] != status + } +} -private fun DbStatusWithUser.toDbStatusReference( - statusKey: MicroBlogKey, - referenceType: ReferenceType, -): DbStatusReferenceWithStatus = - DbStatusReferenceWithStatus( - reference = - DbStatusReference( - _id = Uuid.random().toString(), - referenceType = referenceType, - statusKey = statusKey, - referenceStatusKey = data.statusKey, - ), - status = this, - ) +private suspend fun loadChangedTimeline( + database: CacheDatabase, + incoming: List, +): List { + val existingByPair = + incoming + .groupBy { it.pagingKey } + .flatMap { (pagingKey, rows) -> + rows + .map { it.statusKey } + .distinct() + .chunked(SQL_IN_BATCH_SIZE) + .flatMap { chunk -> + database.pagingTimelineDao().getByPagingKeyAndStatusKeys( + pagingKey = pagingKey, + statusKeys = chunk, + ) + } + }.associateBy { it.pagingKey to it.statusKey } + return incoming.filter { timeline -> + existingByPair[timeline.pagingKey to timeline.statusKey] != timeline + } +} + +private fun UiTimelineV2.usersInContent(): List = + when (this) { + is UiTimelineV2.Post -> listOfNotNull(user) + is UiTimelineV2.User -> listOf(value) + is UiTimelineV2.UserList -> users + else -> emptyList() + } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/Misskey.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/Misskey.kt deleted file mode 100644 index e06011326..000000000 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/Misskey.kt +++ /dev/null @@ -1,180 +0,0 @@ - -package dev.dimension.flare.data.database.cache.mapper - -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.model.DbEmoji -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.database.cache.model.DbStatus -import dev.dimension.flare.data.database.cache.model.DbStatusWithUser -import dev.dimension.flare.data.database.cache.model.DbUser -import dev.dimension.flare.data.database.cache.model.EmojiContent -import dev.dimension.flare.data.database.cache.model.StatusContent -import dev.dimension.flare.data.database.cache.model.UserContent -import dev.dimension.flare.data.network.misskey.api.model.EmojiSimple -import dev.dimension.flare.data.network.misskey.api.model.Note -import dev.dimension.flare.data.network.misskey.api.model.Notification -import dev.dimension.flare.data.network.misskey.api.model.User -import dev.dimension.flare.data.network.misskey.api.model.UserLite -import dev.dimension.flare.model.AccountType -import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.model.PlatformType -import dev.dimension.flare.model.ReferenceType -import kotlin.time.Instant - -internal object Misskey { - suspend fun save( - accountKey: MicroBlogKey, - pagingKey: String, - database: CacheDatabase, - data: List, - sortIdProvider: (Note) -> Long = { Instant.parse(it.createdAt).toEpochMilliseconds() }, - ) { - saveToDatabase(database, data.toDbPagingTimeline(accountKey, pagingKey, sortIdProvider)) - } -} - -internal fun List.toDb( - accountKey: MicroBlogKey, - pagingKey: String, -): List = - this.map { - createDbPagingTimelineWithStatus( - accountKey = accountKey, - pagingKey = pagingKey, - sortId = Instant.parse(it.createdAt).toEpochMilliseconds(), - status = it.toDbStatusWithUser(accountKey), - references = - listOfNotNull( - if (it.note != null) { - ReferenceType.Notification to listOfNotNull(it.note.toDbStatusWithUser(accountKey)) - } else { - null - }, - ).toMap(), - ) - } - -private fun Notification.toDbStatusWithUser(accountKey: MicroBlogKey): DbStatusWithUser { - val user = this.user?.toDbUser(accountKey.host) - val status = this.toDbStatus(accountKey) - return DbStatusWithUser( - data = status, - user = user, - ) -} - -private fun Notification.toDbStatus(accountKey: MicroBlogKey): DbStatus { - val user = this.user?.toDbUser(accountKey.host) - return DbStatus( - statusKey = - MicroBlogKey( - this.id, - accountKey.host, - ), - userKey = user?.userKey, - content = StatusContent.MisskeyNotification(this), - accountType = AccountType.Specific(accountKey), - text = null, - createdAt = Instant.parse(createdAt), - ) -} - -internal suspend fun List.toDbPagingTimeline( - accountKey: MicroBlogKey, - pagingKey: String, - sortIdProvider: suspend (Note) -> Long = { Instant.parse(it.createdAt).toEpochMilliseconds() }, - pinnedProvider: suspend (Note) -> Boolean = { false }, -): List = - this.map { - createDbPagingTimelineWithStatus( - accountKey = accountKey, - pagingKey = pagingKey, - sortId = sortIdProvider(it), - status = it.toDbStatusWithUser(accountKey, pinnedProvider(it)), - references = - listOfNotNull( - if (it.renote != null) { - if (it.text.isNullOrEmpty() && it.files.isNullOrEmpty() && it.poll == null) { - ReferenceType.Retweet to listOfNotNull(it.renote.toDbStatusWithUser(accountKey)) - } else { - ReferenceType.Quote to listOfNotNull(it.renote.toDbStatusWithUser(accountKey)) - } - } else { - null - }, - if (it.reply != null) { - ReferenceType.Reply to listOfNotNull(it.reply.toDbStatusWithUser(accountKey)) - } else { - null - }, - ).toMap(), - ) - } - -private fun Note.toDbStatusWithUser( - accountKey: MicroBlogKey, - pinned: Boolean = false, -): DbStatusWithUser { - val user = user.toDbUser(accountKey.host) - val status = - DbStatus( - statusKey = - MicroBlogKey( - id = id, - host = user.userKey.host, - ), - content = StatusContent.Misskey(this, pinned), - userKey = user.userKey, - accountType = AccountType.Specific(accountKey), - text = text, - createdAt = Instant.parse(createdAt), - ) - return DbStatusWithUser( - data = status, - user = user, - ) -} - -internal fun UserLite.toDbUser(accountHost: String) = - DbUser( - userKey = - MicroBlogKey( - id = id, - host = accountHost, - ), - platformType = PlatformType.Misskey, - name = name ?: "", - handle = username, - content = UserContent.MisskeyLite(this), - host = - if (host.isNullOrEmpty()) { - accountHost - } else { - host - }, - ) - -internal fun User.toDbUser(accountHost: String) = - DbUser( - userKey = - MicroBlogKey( - id = id, - host = accountHost, - ), - platformType = dev.dimension.flare.model.PlatformType.Misskey, - name = name ?: "", - handle = username, - content = UserContent.Misskey(this), - host = - if (host.isNullOrEmpty()) { - accountHost - } else { - host - }, - ) - -internal fun List.toDb(host: String): DbEmoji = - DbEmoji( - host = host, - content = EmojiContent.Misskey(this), - ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/User.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/User.kt new file mode 100644 index 000000000..832266126 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/User.kt @@ -0,0 +1,54 @@ +package dev.dimension.flare.data.database.cache.mapper + +import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.model.DbUser +import dev.dimension.flare.ui.model.UiProfile +import kotlinx.coroutines.flow.firstOrNull + +internal fun UiProfile.toDbUser(host: String = this.host ?: key.host) = + DbUser( + userKey = key, + name = name.raw, + canonicalHandle = handle.canonical, + host = host, + content = this, + ) + +internal suspend fun CacheDatabase.upsertUser(user: DbUser) { + upsertUsers(listOf(user)) +} + +internal suspend fun CacheDatabase.upsertUsers(users: List) { + if (users.isEmpty()) { + return + } + val distinctUsers = users.distinctBy { it.userKey } + val existingUsers = + userDao() + .findByKeys(distinctUsers.map { it.userKey }) + .firstOrNull() + .orEmpty() + .associateBy { it.userKey } + val changedUsers = + distinctUsers.mapNotNull { user -> + val existing = existingUsers[user.userKey] + val merged = existing?.let { user.mergeWith(it) } ?: user + if (merged == existing) { + null + } else { + merged + } + } + if (changedUsers.isEmpty()) { + return + } + userDao().insertAll(changedUsers) +} + +private fun DbUser.mergeWith(existing: DbUser): DbUser = + copy( + name = name.ifBlank { existing.name }, + canonicalHandle = canonicalHandle.ifBlank { existing.canonicalHandle }, + host = host.ifBlank { existing.host }, + content = content.mergeWith(existing.content), + ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/VVO.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/VVO.kt deleted file mode 100644 index 32ba73d6e..000000000 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/VVO.kt +++ /dev/null @@ -1,138 +0,0 @@ -package dev.dimension.flare.data.database.cache.mapper - -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.database.cache.model.DbStatus -import dev.dimension.flare.data.database.cache.model.DbStatusWithUser -import dev.dimension.flare.data.database.cache.model.DbUser -import dev.dimension.flare.data.database.cache.model.UserContent -import dev.dimension.flare.data.network.vvo.model.Comment -import dev.dimension.flare.data.network.vvo.model.Status -import dev.dimension.flare.data.network.vvo.model.User -import dev.dimension.flare.model.AccountType -import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.model.PlatformType -import dev.dimension.flare.model.ReferenceType -import dev.dimension.flare.model.vvoHost -import kotlin.time.Clock - -internal object VVO { - suspend fun saveStatus( - accountKey: MicroBlogKey, - pagingKey: String, - database: dev.dimension.flare.data.database.cache.CacheDatabase, - statuses: List, - sortIdProvider: (Status) -> Long = { it.createdAt?.toEpochMilliseconds() ?: 0L }, - ) { - val items = - statuses.map { - it.toDbPagingTimeline(accountKey, pagingKey, sortIdProvider) - } - saveToDatabase(database, items) - } - - suspend fun saveComment( - accountKey: MicroBlogKey, - pagingKey: String, - database: dev.dimension.flare.data.database.cache.CacheDatabase, - statuses: List, - sortIdProvider: (Comment) -> Long = { it.createdAt?.toEpochMilliseconds() ?: 0L }, - ) { - val items = - statuses.map { - it.toDbPagingTimeline(accountKey, pagingKey, sortIdProvider) - } - saveToDatabase(database, items) - } -} - -internal suspend fun Status.toDbPagingTimeline( - accountKey: MicroBlogKey, - pagingKey: String, - sortIdProvider: suspend (Status) -> Long = { it.createdAt?.toEpochMilliseconds() ?: 0L }, -): DbPagingTimelineWithStatus = - createDbPagingTimelineWithStatus( - accountKey = accountKey, - pagingKey = pagingKey, - sortId = sortIdProvider(this), - status = this.toDbStatusWithUser(accountKey), - references = - listOfNotNull( - if (this.retweetedStatus != null) { - ReferenceType.Retweet to listOfNotNull(this.retweetedStatus.toDbStatusWithUser(accountKey)) - } else { - null - }, - ).toMap(), - ) - -private fun Status.toDbStatusWithUser(accountKey: MicroBlogKey): DbStatusWithUser { - val user = this.user?.toDbUser() - val status = this.toDbStatus(accountKey) - return DbStatusWithUser( - data = status, - user = user, - ) -} - -private fun Status.toDbStatus(accountKey: MicroBlogKey): DbStatus = - DbStatus( - statusKey = MicroBlogKey(id = id, host = vvoHost), - accountType = AccountType.Specific(accountKey), - userKey = user?.id?.let { MicroBlogKey(id = it.toString(), host = vvoHost) }, - content = - dev.dimension.flare.data.database.cache.model.StatusContent - .VVO(data = this), - text = rawText, - createdAt = createdAt ?: Clock.System.now(), - ) - -internal suspend fun Comment.toDbPagingTimeline( - accountKey: MicroBlogKey, - pagingKey: String, - sortIdProvider: suspend (Comment) -> Long = { it.createdAt?.toEpochMilliseconds() ?: 0L }, -): DbPagingTimelineWithStatus = - createDbPagingTimelineWithStatus( - accountKey = accountKey, - pagingKey = pagingKey, - sortId = sortIdProvider(this), - status = this.toDbStatusWithUser(accountKey), - references = - commentList.orEmpty().associate { - ReferenceType.Reply to listOfNotNull(it.toDbStatusWithUser(accountKey)) - }, - ) - -private fun Comment.toDbStatusWithUser(accountKey: MicroBlogKey): DbStatusWithUser { - val user = this.user?.toDbUser() - val status = this.toDbStatus(accountKey) - return DbStatusWithUser( - data = status, - user = user, - ) -} - -private fun Comment.toDbStatus(accountKey: MicroBlogKey): DbStatus = - DbStatus( - statusKey = MicroBlogKey(id = id, host = vvoHost), - accountType = AccountType.Specific(accountKey), - userKey = user?.id?.let { MicroBlogKey(id = it.toString(), host = vvoHost) }, - content = - dev.dimension.flare.data.database.cache.model.StatusContent - .VVOComment(data = this), - text = null, - createdAt = createdAt ?: Clock.System.now(), - ) - -internal fun User.toDbUser(): DbUser? = - screenName?.let { - DbUser( - handle = it, - host = vvoHost, - name = screenName, - userKey = MicroBlogKey(id = id.toString(), host = vvoHost), - platformType = PlatformType.VVo, - content = - UserContent - .VVO(data = this), - ) - } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/XQT.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/XQT.kt index b6524e5f2..aa2edc6a6 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/XQT.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/XQT.kt @@ -5,13 +5,8 @@ import dev.dimension.flare.data.database.cache.model.DbDirectMessageTimeline import dev.dimension.flare.data.database.cache.model.DbMessageItem import dev.dimension.flare.data.database.cache.model.DbMessageRoom import dev.dimension.flare.data.database.cache.model.DbMessageRoomReference -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.database.cache.model.DbStatus -import dev.dimension.flare.data.database.cache.model.DbStatusWithUser import dev.dimension.flare.data.database.cache.model.DbUser import dev.dimension.flare.data.database.cache.model.MessageContent -import dev.dimension.flare.data.database.cache.model.StatusContent -import dev.dimension.flare.data.database.cache.model.UserContent import dev.dimension.flare.data.network.xqt.model.CursorType import dev.dimension.flare.data.network.xqt.model.InboxConversation import dev.dimension.flare.data.network.xqt.model.InboxTimelineEntry @@ -30,7 +25,6 @@ import dev.dimension.flare.data.network.xqt.model.TimelineTweet import dev.dimension.flare.data.network.xqt.model.TimelineUser import dev.dimension.flare.data.network.xqt.model.Tweet import dev.dimension.flare.data.network.xqt.model.TweetTombstone -import dev.dimension.flare.data.network.xqt.model.TweetUnion import dev.dimension.flare.data.network.xqt.model.TweetWithVisibilityResults import dev.dimension.flare.data.network.xqt.model.User import dev.dimension.flare.data.network.xqt.model.UserLegacy @@ -41,25 +35,9 @@ import dev.dimension.flare.data.network.xqt.model.legacy.TopLevel import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType -import dev.dimension.flare.model.ReferenceType -import dev.dimension.flare.ui.model.mapper.name -import dev.dimension.flare.ui.model.mapper.parseXQTCustomDateTime -import dev.dimension.flare.ui.model.mapper.screenName -import kotlin.time.Clock +import dev.dimension.flare.ui.model.mapper.render internal object XQT { - suspend fun save( - accountKey: MicroBlogKey, - pagingKey: String, - database: CacheDatabase, - tweet: List, - sortIdProvider: (XQTTimeline) -> Long = { it.sortedIndex }, - ) { - val items = - tweet.mapNotNull { it.toDbPagingTimeline(accountKey, pagingKey, sortIdProvider) } - saveToDatabase(database, items) - } - suspend fun saveDM( accountKey: MicroBlogKey, database: CacheDatabase, @@ -136,7 +114,7 @@ internal object XQT { } database.messageDao().insert(rooms) } - database.userDao().insertAll( + database.upsertUsers( users ?.values .orEmpty() @@ -180,7 +158,7 @@ private fun List.toDbUser(accountKey: MicroBlogKey): List { }, isBlueVerified = it.isBlueVerified == true, restId = it.idStr, - ).toDbUser(accountKey) + ).render(accountKey).toDbUser(host = accountKey.host) } } @@ -201,113 +179,6 @@ private fun InboxTimelineEntry.toDbMessageItem( ) } -private fun TweetUnion.getRetweet(): TweetUnion? = - when (this) { - is Tweet -> this.legacy?.retweetedStatusResult?.result - is TweetTombstone -> null - is TweetWithVisibilityResults -> - this.tweet.legacy - ?.retweetedStatusResult - ?.result - } - -private fun TweetUnion.getQuoted(): TweetUnion? = - when (this) { - is Tweet -> this.quotedStatusResult?.result - is TweetTombstone -> null - is TweetWithVisibilityResults -> this.tweet.quotedStatusResult?.result - } - -internal fun XQTTimeline.toDbPagingTimeline( - accountKey: MicroBlogKey, - pagingKey: String, - sortIdProvider: (XQTTimeline) -> Long = { sortedIndex }, -): DbPagingTimelineWithStatus? = - tweets.toDbStatusWithUser(accountKey)?.let { tweet -> - createDbPagingTimelineWithStatus( - accountKey = accountKey, - pagingKey = pagingKey, - sortId = sortIdProvider(this), - status = tweet, - references = - listOfNotNull( - tweets.tweetResults.result?.getRetweet()?.toDbStatusWithUser(accountKey)?.let { - ReferenceType.Retweet to listOfNotNull(it) - }, - ( - tweets.tweetResults.result - ?.getRetweet() - ?.getQuoted() ?: tweets.tweetResults.result?.getQuoted() - )?.toDbStatusWithUser(accountKey) - ?.let { - ReferenceType.Quote to listOfNotNull(it) - }, - parents - .mapNotNull { it.tweets.toDbStatusWithUser(accountKey) } - .takeIf { it.isNotEmpty() } - ?.let { ReferenceType.Reply to it }, - ).toMap(), - ) - } - -private fun TimelineTweet.toDbStatusWithUser(accountKey: MicroBlogKey): DbStatusWithUser? = - tweetResults.result?.toDbStatusWithUser(accountKey) - -private fun TweetUnion.toDbStatusWithUser(accountKey: MicroBlogKey): DbStatusWithUser? = - when (this) { - is Tweet -> toDbStatusWithUser(this, accountKey) - // You’re unable to view this Post because - // this account owner limits who can view their Posts. Learn more - // throw IllegalStateException("Tweet tombstone should not be saved") - is TweetTombstone -> null - is TweetWithVisibilityResults -> toDbStatusWithUser(this.tweet, accountKey) - } - -private fun toDbStatusWithUser( - tweet: Tweet, - accountKey: MicroBlogKey, -): DbStatusWithUser? { - val user = - tweet.core - ?.userResults - ?.result - ?.let { - it as? User - }?.toDbUser(accountKey) ?: return null - return DbStatusWithUser( - data = - DbStatus( - statusKey = - MicroBlogKey( - id = tweet.restId, - host = accountKey.host, - ), - content = StatusContent.XQT(tweet), - userKey = user.userKey, - accountType = AccountType.Specific(accountKey), - text = tweet.legacy?.fullText, - createdAt = - tweet.legacy?.createdAt?.let { parseXQTCustomDateTime(it) } - ?: Clock.System.now(), - ), - user = user, - ) -} - -internal fun User.toDbUser(accountKey: MicroBlogKey) = - DbUser( - userKey = - MicroBlogKey( - id = restId, - host = accountKey.host, - ), - platformType = PlatformType.xQt, - name = name, - handle = screenName, - host = accountKey.host, - content = UserContent.XQT(this), - ) - internal data class XQTTimeline( val parents: List, val tweets: TimelineTweet, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbDirectMessage.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbDirectMessage.kt index f855e9c5a..299ebf326 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbDirectMessage.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbDirectMessage.kt @@ -2,6 +2,7 @@ package dev.dimension.flare.data.database.cache.model import androidx.room.Embedded import androidx.room.Entity +import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.Relation import androidx.room.TypeConverter @@ -16,10 +17,13 @@ import kotlinx.serialization.Serializable @Entity( indices = [ - androidx.room.Index( + Index( value = ["accountType", "roomKey"], unique = true, ), + Index( + value = ["accountType", "sortId"], + ), ], ) internal data class DbDirectMessageTimeline( @@ -50,7 +54,13 @@ internal data class DbMessageRoom( val messageKey: MicroBlogKey?, ) -@Entity +@Entity( + indices = [ + Index( + value = ["roomKey"], + ), + ], +) internal data class DbMessageRoomReference( val roomKey: MicroBlogKey, val userKey: MicroBlogKey, @@ -85,7 +95,13 @@ internal data class DbMessageRoomWithLastMessageAndUser( val users: List, ) -@Entity +@Entity( + indices = [ + Index( + value = ["roomKey", "timestamp"], + ), + ], +) internal data class DbMessageItem( @PrimaryKey val messageKey: MicroBlogKey, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbList.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbList.kt index 88938acce..b8859c84c 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbList.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbList.kt @@ -41,6 +41,9 @@ internal class ListContentConverters { @Entity( indices = [ + Index( + value = ["pagingKey"], + ), Index( value = ["accountType", "listKey", "pagingKey"], unique = true, @@ -68,6 +71,9 @@ internal data class DbListWithContent( @Entity( indices = [ + Index( + value = ["memberKey"], + ), Index( value = ["listKey", "memberKey"], unique = true, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbPagingTimeline.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbPagingTimeline.kt index e47f8ca17..d778840c3 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbPagingTimeline.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbPagingTimeline.kt @@ -6,9 +6,6 @@ import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.Relation import androidx.room.TypeConverter -import dev.dimension.flare.common.decodeJson -import dev.dimension.flare.common.encodeJson -import dev.dimension.flare.model.DbAccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.ReferenceType import kotlin.time.Instant @@ -17,13 +14,15 @@ import kotlin.uuid.Uuid @Entity( indices = [ Index( - value = ["accountType", "statusKey", "pagingKey"], + value = ["statusKey", "pagingKey"], unique = true, ), + Index( + value = ["pagingKey", "sortId"], + ), ], ) internal data class DbPagingTimeline( - val accountType: DbAccountType, val pagingKey: String, val statusKey: MicroBlogKey, val sortId: Long, @@ -53,8 +52,6 @@ internal data class DbPagingTimelineWithStatus( internal data class DbStatusWithUser( @Embedded val data: DbStatus, - @Relation(parentColumn = "userKey", entityColumn = "userKey") - val user: DbUser?, ) internal data class DbStatusReferenceWithStatus( @@ -80,12 +77,6 @@ internal data class DbStatusWithReference( ) internal class StatusConverter { - @TypeConverter - fun fromStatusContent(value: StatusContent): String = value.encodeJson() - - @TypeConverter - fun toStatusContent(value: String): StatusContent = value.decodeJson() - @TypeConverter fun fromReferenceType(value: ReferenceType): String = value.name diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbStatus.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbStatus.kt index 2f2a62898..5a60756a7 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbStatus.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbStatus.kt @@ -5,7 +5,7 @@ import androidx.room.Index import androidx.room.PrimaryKey import dev.dimension.flare.model.DbAccountType import dev.dimension.flare.model.MicroBlogKey -import kotlin.time.Instant +import dev.dimension.flare.ui.model.UiTimelineV2 @Entity( indices = [Index(value = ["statusKey", "accountType"], unique = true)], @@ -13,10 +13,8 @@ import kotlin.time.Instant internal data class DbStatus( val statusKey: MicroBlogKey, val accountType: DbAccountType, - val userKey: MicroBlogKey?, - val content: StatusContent, + val content: UiTimelineV2, val text: String?, // For Searching - val createdAt: Instant, @PrimaryKey val id: String = "${accountType}_$statusKey", ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbStatusReference.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbStatusReference.kt index b7e2d5d47..8f7e76e92 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbStatusReference.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbStatusReference.kt @@ -9,6 +9,11 @@ import dev.dimension.flare.model.ReferenceType @Entity( tableName = "status_reference", indices = [ + Index( + value = [ + "statusKey", + ], + ), Index( value = [ "referenceType", diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbUser.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbUser.kt index 131fb2f7c..bcaf3ec15 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbUser.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbUser.kt @@ -3,31 +3,33 @@ package dev.dimension.flare.data.database.cache.model import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey -import androidx.room.TypeConverter -import dev.dimension.flare.common.decodeJson -import dev.dimension.flare.common.encodeJson +import dev.dimension.flare.model.DbAccountType import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.UiRelation @Entity( indices = [ - Index(value = ["handle", "host", "platformType"], unique = true), + Index(value = ["canonicalHandle", "host"], unique = true), ], ) internal data class DbUser( @PrimaryKey val userKey: MicroBlogKey, - val platformType: PlatformType, val name: String, - val handle: String, + val canonicalHandle: String, val host: String, - val content: UserContent, + val content: UiProfile, ) -internal class UserContentConverters { - @TypeConverter - fun fromUserContent(content: UserContent): String = content.encodeJson() - - @TypeConverter - fun toUserContent(value: String): UserContent = value.decodeJson() -} +@Entity( + primaryKeys = ["accountType", "userKey"], + indices = [ + Index(value = ["accountType", "userKey"], unique = true), + ], +) +internal data class DbUserRelation( + val accountType: DbAccountType, + val userKey: MicroBlogKey, + val relation: UiRelation, +) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbUserHistory.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbUserHistory.kt index 2aeb72e1d..d5f4e06e3 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbUserHistory.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbUserHistory.kt @@ -11,6 +11,7 @@ import dev.dimension.flare.model.MicroBlogKey @Entity( indices = [ Index(value = ["userKey", "accountType"], unique = true), + Index(value = ["lastVisit"]), ], ) internal data class DbUserHistory( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/EmojiContent.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/EmojiContent.kt index 2a416d6fd..ce8b5bac4 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/EmojiContent.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/EmojiContent.kt @@ -1,34 +1,11 @@ package dev.dimension.flare.data.database.cache.model -import dev.dimension.flare.data.network.mastodon.api.model.Emoji -import dev.dimension.flare.data.network.misskey.api.model.EmojiSimple -import dev.dimension.flare.data.network.vvo.model.EmojiData -import kotlinx.serialization.SerialName +import dev.dimension.flare.common.SerializableImmutableList +import dev.dimension.flare.common.SerializableImmutableMap +import dev.dimension.flare.ui.model.UiEmoji import kotlinx.serialization.Serializable @Serializable -internal sealed interface EmojiContent { - @Serializable - @SerialName("Mastodon") - data class Mastodon internal constructor( - internal val data: List, - ) : EmojiContent - - @Serializable - @SerialName("Misskey") - data class Misskey internal constructor( - internal val data: List, - ) : EmojiContent - - @Serializable - @SerialName("VVO") - data class VVO internal constructor( - internal val data: EmojiData, - ) : EmojiContent - - @Serializable - @SerialName("FavIcon") - data class FavIcon internal constructor( - internal val data: String, - ) : EmojiContent -} +internal data class EmojiContent( + val data: SerializableImmutableMap>, +) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/StatusContent.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/StatusContent.kt deleted file mode 100644 index 7b86175fd..000000000 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/StatusContent.kt +++ /dev/null @@ -1,162 +0,0 @@ -package dev.dimension.flare.data.database.cache.model - -import app.bsky.feed.FeedViewPostReasonUnion -import app.bsky.feed.PostView -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.network.rss.model.Feed -import dev.dimension.flare.model.AccountType -import dev.dimension.flare.model.MicroBlogKey -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -internal sealed interface StatusContent { - @Serializable - @SerialName("mastodon") - data class Mastodon internal constructor( - internal val data: dev.dimension.flare.data.network.mastodon.api.model.Status, - ) : StatusContent - - @Serializable - @SerialName("mastodon-notification") - data class MastodonNotification internal constructor( - internal val data: dev.dimension.flare.data.network.mastodon.api.model.Notification, - ) : StatusContent - - @Serializable - @SerialName("misskey") - data class Misskey internal constructor( - internal val data: dev.dimension.flare.data.network.misskey.api.model.Note, - val pinned: Boolean = false, - ) : StatusContent - - @Serializable - @SerialName("misskey-notification") - data class MisskeyNotification internal constructor( - internal val data: dev.dimension.flare.data.network.misskey.api.model.Notification, - ) : StatusContent - - @Serializable - @SerialName("bluesky") - data class Bluesky internal constructor( - val data: PostView, - ) : StatusContent - - @Serializable - @SerialName("bluesky-reason") - data class BlueskyReason internal constructor( - val reason: FeedViewPostReasonUnion, - ) : StatusContent - - @Serializable - sealed interface BlueskyNotification : StatusContent { - @Serializable - @SerialName("bluesky-notification-user-list") - data class UserList internal constructor( - val data: List, - val post: PostView?, - ) : BlueskyNotification - - @Serializable - @SerialName("bluesky-notification-post") - data class Post internal constructor( - val post: PostView, - ) : BlueskyNotification - - @Serializable - @SerialName("bluesky-notification-normal") - data class Normal internal constructor( - val data: app.bsky.notification.ListNotificationsNotification, - ) : BlueskyNotification - } - - @Serializable - @SerialName("XQT") - data class XQT internal constructor( - internal val data: dev.dimension.flare.data.network.xqt.model.Tweet, - ) : StatusContent - - @Serializable - @SerialName("vvo") - data class VVO internal constructor( - internal val data: dev.dimension.flare.data.network.vvo.model.Status, - ) : StatusContent - - @Serializable - @SerialName("vvo-comment") - data class VVOComment internal constructor( - internal val data: dev.dimension.flare.data.network.vvo.model.Comment, - ) : StatusContent - - @Serializable - data class Rss( - val data: RssContent, - ) : StatusContent { - @Serializable - sealed interface RssContent { - @Serializable - @SerialName("atom") - data class Atom internal constructor( - internal val data: Feed.Atom.Entry, - internal val source: String, - internal val icon: String?, - internal val openInBrowser: Boolean, - ) : RssContent - - @Serializable - @SerialName("rss20") - data class Rss20 internal constructor( - internal val data: Feed.Rss20.Item, - internal val source: String, - internal val icon: String?, - internal val openInBrowser: Boolean, - ) : RssContent - - @Serializable - @SerialName("rdf") - data class RDF internal constructor( - internal val data: Feed.RDF.Item, - internal val source: String, - internal val icon: String?, - internal val openInBrowser: Boolean, - ) : RssContent - } - } - - @Serializable - @SerialName("Test") - data class Test internal constructor( - internal val data: String, - ) : StatusContent -} - -internal suspend inline fun updateStatusUseCase( - statusKey: MicroBlogKey, - accountKey: MicroBlogKey, - cacheDatabase: CacheDatabase, - update: (content: T) -> T, -) { - val status = cacheDatabase.statusDao().get(statusKey, accountType = AccountType.Specific(accountKey)).firstOrNull() - if (status != null && status.content is T) { - cacheDatabase.statusDao().update( - statusKey = statusKey, - accountType = AccountType.Specific(accountKey), - content = update(status.content), - ) - } -} - -internal suspend inline fun updateUserUseCase( - userKey: MicroBlogKey, - cacheDatabase: CacheDatabase, - update: (content: T) -> T, -) { - val user = cacheDatabase.userDao().findByKey(userKey).firstOrNull() - if (user != null && user.content is T) { - cacheDatabase.userDao().update( - userKey = userKey, - content = update(user.content), - ) - } -} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/UserContent.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/UserContent.kt deleted file mode 100644 index 117a64b94..000000000 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/UserContent.kt +++ /dev/null @@ -1,59 +0,0 @@ -package dev.dimension.flare.data.database.cache.model - -import app.bsky.actor.ProfileViewBasic -import app.bsky.actor.ProfileViewDetailed -import dev.dimension.flare.data.network.xqt.model.User -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -// https://github.com/cashapp/sqldelight/issues/1333 -@Serializable -internal sealed interface UserContent { - @Serializable - @SerialName("Mastodon") - data class Mastodon internal constructor( - internal val data: dev.dimension.flare.data.network.mastodon.api.model.Account, - ) : UserContent - - @Serializable - @SerialName("Misskey") - data class Misskey internal constructor( - internal val data: dev.dimension.flare.data.network.misskey.api.model.User, - ) : UserContent - - @Serializable - @SerialName("MisskeyLite") - data class MisskeyLite internal constructor( - internal val data: dev.dimension.flare.data.network.misskey.api.model.UserLite, - ) : UserContent - - @Serializable - @SerialName("Bluesky") - data class Bluesky( - val data: ProfileViewDetailed, - ) : UserContent - - @Serializable - @SerialName("BlueskyLite") - data class BlueskyLite( - val data: ProfileViewBasic, - ) : UserContent - - @Serializable - @SerialName("XQT") - data class XQT internal constructor( - internal val data: User, - ) : UserContent - - @Serializable - @SerialName("VVO") - data class VVO internal constructor( - internal val data: dev.dimension.flare.data.network.vvo.model.User, - ) : UserContent - - @Serializable - @SerialName("Test") - data class Test internal constructor( - internal val data: String, - ) : UserContent -} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyDataSource.kt index ce00d3ab2..63717a25b 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyDataSource.kt @@ -16,10 +16,6 @@ import app.bsky.feed.GetPostsQueryParams import app.bsky.feed.Post import app.bsky.feed.PostEmbedUnion import app.bsky.feed.PostReplyRef -import app.bsky.feed.ViewerState -import app.bsky.graph.MuteActorRequest -import app.bsky.graph.UnmuteActorRequest -import app.bsky.notification.ListNotificationsQueryParams import app.bsky.unspecced.GetPopularFeedGeneratorsQueryParams import chat.bsky.convo.DeleteMessageForSelfRequest import chat.bsky.convo.DeletedMessageView @@ -35,7 +31,6 @@ import chat.bsky.convo.MessageInput import chat.bsky.convo.MessageView import chat.bsky.convo.SendMessageRequest import chat.bsky.convo.UpdateReadRequest -import com.atproto.identity.ResolveHandleQueryParams import com.atproto.moderation.CreateReportRequest import com.atproto.moderation.CreateReportRequestSubjectUnion import com.atproto.moderation.Token @@ -47,46 +42,46 @@ import dev.dimension.flare.common.BasePagingSource import dev.dimension.flare.common.CacheData import dev.dimension.flare.common.Cacheable import dev.dimension.flare.common.FileType -import dev.dimension.flare.common.InAppNotification -import dev.dimension.flare.common.MemCacheable import dev.dimension.flare.common.encodeJson import dev.dimension.flare.data.database.app.AppDatabase import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.connect import dev.dimension.flare.data.database.cache.mapper.Bluesky -import dev.dimension.flare.data.database.cache.mapper.toDbUser import dev.dimension.flare.data.database.cache.model.MessageContent -import dev.dimension.flare.data.database.cache.model.StatusContent -import dev.dimension.flare.data.database.cache.model.updateStatusUseCase +import dev.dimension.flare.data.datasource.microblog.ActionMenu import dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataSource import dev.dimension.flare.data.datasource.microblog.ComposeConfig import dev.dimension.flare.data.datasource.microblog.ComposeData import dev.dimension.flare.data.datasource.microblog.ComposeProgress import dev.dimension.flare.data.datasource.microblog.ComposeType +import dev.dimension.flare.data.datasource.microblog.DatabaseUpdater import dev.dimension.flare.data.datasource.microblog.DirectMessageDataSource import dev.dimension.flare.data.datasource.microblog.NotificationFilter -import dev.dimension.flare.data.datasource.microblog.ProfileAction +import dev.dimension.flare.data.datasource.microblog.PostEvent import dev.dimension.flare.data.datasource.microblog.ProfileTab -import dev.dimension.flare.data.datasource.microblog.RelationDataSource -import dev.dimension.flare.data.datasource.microblog.StatusEvent import dev.dimension.flare.data.datasource.microblog.createSendingDirectMessage -import dev.dimension.flare.data.datasource.microblog.list.ListDataSource -import dev.dimension.flare.data.datasource.microblog.list.ListHandler -import dev.dimension.flare.data.datasource.microblog.list.ListLoader -import dev.dimension.flare.data.datasource.microblog.list.ListMemberHandler -import dev.dimension.flare.data.datasource.microblog.list.ListMemberLoader -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineLoader +import dev.dimension.flare.data.datasource.microblog.datasource.ListDataSource +import dev.dimension.flare.data.datasource.microblog.datasource.NotificationDataSource +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.UserDataSource +import dev.dimension.flare.data.datasource.microblog.handler.ListHandler +import dev.dimension.flare.data.datasource.microblog.handler.ListMemberHandler +import dev.dimension.flare.data.datasource.microblog.handler.NotificationHandler +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.ListLoader +import dev.dimension.flare.data.datasource.microblog.loader.ListMemberLoader +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader +import dev.dimension.flare.data.datasource.microblog.paging.notSupported import dev.dimension.flare.data.datasource.microblog.pagingConfig -import dev.dimension.flare.data.datasource.microblog.relationKeyWithUserKey -import dev.dimension.flare.data.datasource.microblog.timelinePager import dev.dimension.flare.data.network.bluesky.BlueskyService import dev.dimension.flare.data.network.bluesky.model.DidDoc import dev.dimension.flare.data.repository.AccountRepository -import dev.dimension.flare.data.repository.LocalFilterRepository import dev.dimension.flare.data.repository.tryRun import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.model.PlatformType import dev.dimension.flare.shared.image.ImageCompressor import dev.dimension.flare.ui.model.UiAccount import dev.dimension.flare.ui.model.UiDMItem @@ -94,13 +89,13 @@ import dev.dimension.flare.ui.model.UiDMRoom import dev.dimension.flare.ui.model.UiHashtag import dev.dimension.flare.ui.model.UiList import dev.dimension.flare.ui.model.UiProfile -import dev.dimension.flare.ui.model.UiRelation import dev.dimension.flare.ui.model.UiState -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.blueskyLike +import dev.dimension.flare.ui.model.mapper.blueskyReblog import dev.dimension.flare.ui.model.mapper.bskyJson import dev.dimension.flare.ui.model.mapper.parseBskyFacets import dev.dimension.flare.ui.model.mapper.render -import dev.dimension.flare.ui.model.toUi import dev.dimension.flare.ui.presenter.compose.ComposeStatus import dev.dimension.flare.ui.presenter.status.action.BlueskyReportStatusState import kotlinx.collections.immutable.ImmutableList @@ -132,17 +127,18 @@ import kotlin.time.Clock internal class BlueskyDataSource( override val accountKey: MicroBlogKey, ) : AuthenticatedMicroblogDataSource, + NotificationDataSource, + UserDataSource, + PostDataSource, KoinComponent, - StatusEvent.Bluesky, ListDataSource, + RelationDataSource, DirectMessageDataSource, - RelationDataSource { + PostEventHandler.Handler { private val database: CacheDatabase by inject() private val appDatabase: AppDatabase by inject() - private val localFilterRepository: LocalFilterRepository by inject() private val coroutineScope: CoroutineScope by inject() private val accountRepository: AccountRepository by inject() - private val inAppNotification: InAppNotification by inject() private val imageCompressor: ImageCompressor by inject() private val credentialFlow by lazy { accountRepository.credentialFlow(accountKey) @@ -180,99 +176,88 @@ internal class BlueskyDataSource( } ?: service } - override fun homeTimeline() = - HomeTimelineRemoteMediator( - service, - accountKey, - database, - inAppNotification = inAppNotification, + val loader by lazy { + BlueskyLoader( + accountKey = accountKey, + service = service, ) + } - override fun notification( - type: NotificationFilter, - pageSize: Int, - scope: CoroutineScope, - ): Flow> = - timelinePager( - pageSize = pageSize, - database = database, - scope = scope, - filterFlow = localFilterRepository.getFlow(forNotification = true), - accountRepository = accountRepository, - mediator = - when (type) { - NotificationFilter.All -> - NotificationRemoteMediator( - service, - accountKey, - database, - onClearMarker = { - MemCacheable.update(notificationMarkerKey, 0) - }, - ) + override val notificationHandler by lazy { + NotificationHandler( + accountKey = accountKey, + loader = loader, + ) + } - else -> throw IllegalArgumentException("Unsupported notification filter") - }, + override val userHandler by lazy { + UserHandler( + host = accountKey.host, + loader = loader, ) + } - override val supportedNotificationFilter: List - get() = listOf(NotificationFilter.All) + override val postHandler by lazy { + PostHandler( + accountType = AccountType.Specific(accountKey), + loader = loader, + ) + } - override fun userByAcct(acct: String): CacheData { - val (name, host) = MicroBlogKey.valueOf(acct) - return Cacheable( - fetchSource = { - val user = - service - .getProfile(GetProfileQueryParams(actor = Handle(handle = name))) - .requireResponse() - .toDbUser(accountKey.host) - database.userDao().insert(user) - }, - cacheSource = { - database - .userDao() - .findByHandleAndHost(name, host, PlatformType.Bluesky) - .distinctUntilChanged() - .mapNotNull { it?.render(accountKey) } - }, + override val relationHandler by lazy { + RelationHandler( + dataSource = loader, + accountType = AccountType.Specific(accountKey), ) } - override fun userById(id: String): CacheData = - Cacheable( - fetchSource = { - val user = - service - .getProfile(GetProfileQueryParams(actor = Did(did = id))) - .requireResponse() - .toDbUser(accountKey.host) - database.userDao().insert(user) - }, - cacheSource = { - database - .userDao() - .findByKey(MicroBlogKey(id, accountKey.host)) - .distinctUntilChanged() - .mapNotNull { it?.render(accountKey) } - }, + override val supportedRelationTypes: Set + get() = loader.supportedTypes + + override val postEventHandler by lazy { + PostEventHandler( + accountType = AccountType.Specific(accountKey), + handler = this, ) + } - override fun relation(userKey: MicroBlogKey): Flow> = - MemCacheable( - relationKeyWithUserKey(userKey), - ) { - val user = - service - .getProfile(GetProfileQueryParams(actor = Did(did = userKey.id))) - .requireResponse() - UiRelation( - following = user.viewer?.following?.atUri != null, - isFans = user.viewer?.followedBy?.atUri != null, - blocking = user.viewer?.blockedBy ?: false, - muted = user.viewer?.muted ?: false, - ) - }.toUi() + override suspend fun handle( + event: PostEvent, + updater: DatabaseUpdater, + ) { + require(event is PostEvent.Bluesky) + when (event) { + is PostEvent.Bluesky.Bookmark -> + bookmark(event, updater) + is PostEvent.Bluesky.Like -> + like(event, updater) + is PostEvent.Bluesky.Reblog -> + reblog(event, updater) + } + } + + override fun homeTimeline() = + HomeTimelineRemoteMediator( + service, + accountKey, + ) + + override fun notification(type: NotificationFilter): RemoteLoader = + when (type) { + NotificationFilter.All -> + NotificationRemoteMediator( + service, + accountKey, + onClearMarker = { + notificationHandler.clear() + }, + ) + + else -> notSupported() + } + + override val supportedNotificationFilter: List + get() = listOf(NotificationFilter.All) override fun userTimeline( userKey: MicroBlogKey, @@ -280,7 +265,6 @@ internal class BlueskyDataSource( ) = UserTimelineRemoteMediator( service, accountKey, - database, userKey, onlyMedia = mediaOnly, ) @@ -290,73 +274,9 @@ internal class BlueskyDataSource( statusKey, service, accountKey, - database, statusOnly = false, ) - override fun status(statusKey: MicroBlogKey): CacheData { - val pagingKey = "status_only_$statusKey" - return Cacheable( - fetchSource = { - val isDid = statusKey.id.startsWith("at://did:") - if (isDid) { - val result = - service - .getPosts( - GetPostsQueryParams( - persistentListOf(AtUri(statusKey.id)), - ), - ).requireResponse() - .posts - .firstOrNull() - .let { - listOfNotNull(it) - } - database.connect { - Bluesky.savePost( - accountKey, - pagingKey, - database, - result, - ) - } - } else { - // "at://${handle}/app.bsky.feed.post/${id}" - val handle = statusKey.id.substringAfter("at://").substringBefore("/") - val id = statusKey.id.substringAfterLast('/') - val did = service.resolveHandle(ResolveHandleQueryParams(Handle(handle))).requireResponse().did - val actualAtUri = AtUri("at://${did.did}/app.bsky.feed.post/$id") - val result = - service - .getPosts( - GetPostsQueryParams( - persistentListOf(actualAtUri), - ), - ).requireResponse() - .posts - .firstOrNull() - .let { - listOfNotNull(it) - } - database.connect { - Bluesky.savePost( - accountKey, - pagingKey, - database, - result, - ) - } - } - }, - cacheSource = { - database - .pagingTimelineDao() - .get(pagingKey, accountType = AccountType.Specific(accountKey)) - .mapNotNull { it?.render(this) } - }, - ) - } - override suspend fun compose( data: ComposeData, progress: (ComposeProgress) -> Unit, @@ -524,207 +444,112 @@ internal class BlueskyDataSource( } } - override fun reblog( - statusKey: MicroBlogKey, - cid: String, - uri: String, - repostUri: String?, + suspend fun reblog( + event: PostEvent.Bluesky.Reblog, + updater: DatabaseUpdater, ) { - coroutineScope.launch { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - ) { content -> - val newUri = - if (repostUri != null) { - null - } else { - AtUri("") - } - val count = - if (repostUri != null) { - (content.data.repostCount ?: 0) - 1 - } else { - (content.data.repostCount ?: 0) + 1 - }.coerceAtLeast(0) - content.copy( - data = - content.data.copy( - viewer = - content.data.viewer?.copy( - repost = newUri, - ) ?: ViewerState( - repost = newUri, - ), - repostCount = count, - ), + val cid = event.cid + val uri = event.uri + val repostUri = event.repostUri + if (repostUri != null) { + if (repostUri.isEmpty()) { + // pending event, do nothing + } else { + service.deleteRecord( + DeleteRecordRequest( + repo = Did(did = accountKey.id), + collection = Nsid("app.bsky.feed.repost"), + rkey = RKey(repostUri.substringAfterLast('/')), + ), ) } - tryRun { - if (repostUri != null) { - service.deleteRecord( - DeleteRecordRequest( + } else { + val response = + service + .createRecord( + CreateRecordRequest( repo = Did(did = accountKey.id), collection = Nsid("app.bsky.feed.repost"), - rkey = RKey(repostUri.substringAfterLast('/')), + record = + app.bsky.feed + .Repost( + subject = + StrongRef( + uri = AtUri(uri), + cid = Cid(cid), + ), + createdAt = + Clock.System + .now(), + ).bskyJson(), ), - ) - } else { - val result = - service - .createRecord( - CreateRecordRequest( - repo = Did(did = accountKey.id), - collection = Nsid("app.bsky.feed.repost"), - record = - app.bsky.feed - .Repost( - subject = - StrongRef( - uri = AtUri(uri), - cid = Cid(cid), - ), - createdAt = - Clock.System - .now(), - ).bskyJson(), - ), - ).requireResponse() - updateStatusUseCase( - statusKey = statusKey, + ).requireResponse() + updater.updateActionMenu( + postKey = event.postKey, + newActionMenu = + ActionMenu.blueskyReblog( accountKey = accountKey, - cacheDatabase = database, - ) { content -> - content.copy( - data = - content.data.copy( - viewer = - content.data.viewer?.copy( - repost = AtUri(result.uri.atUri), - ) ?: ViewerState( - repost = AtUri(result.uri.atUri), - ), - ), - ) - } - } - }.onFailure { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - ) { content -> - val count = - if (repostUri != null) { - (content.data.repostCount ?: 0) + 1 - } else { - (content.data.repostCount ?: 0) - 1 - }.coerceAtLeast(0) - content.copy( - data = - content.data.copy( - viewer = - content.data.viewer?.copy( - repost = repostUri?.let { it1 -> AtUri(it1) }, - ) ?: ViewerState( - repost = repostUri?.let { it1 -> AtUri(it1) }, - ), - repostCount = count, - ), - ) - } - } + postKey = event.postKey, + cid = cid, + uri = uri, + count = event.count + 1, + repostUri = response.uri.atUri, + ), + ) } } - override fun like( - statusKey: MicroBlogKey, - cid: String, - uri: String, - likedUri: String?, + suspend fun like( + event: PostEvent.Bluesky.Like, + updater: DatabaseUpdater, ) { - coroutineScope.launch { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - ) { content -> - val newUri = - if (likedUri != null) { - null - } else { - AtUri("") - } - val count = - if (likedUri != null) { - (content.data.likeCount ?: 0) - 1 - } else { - (content.data.likeCount ?: 0) + 1 - }.coerceAtLeast(0) - content.copy( - data = - content.data.copy( - viewer = - content.data.viewer?.copy( - like = newUri, - ) ?: ViewerState( - like = newUri, - ), - likeCount = count, - ), - ) + val cid = event.cid + val uri = event.uri + val likedUri = event.likedUri + if (likedUri != null) { + if (likedUri.isEmpty()) { + // pending event, do nothing + } else { + deleteLikeRecord(likedUri) } - tryRun { - if (likedUri != null) { - deleteLikeRecord(likedUri) - } else { - val result = - createLikeRecord(cid, uri) - updateStatusUseCase( - statusKey = statusKey, + } else { + val response = createLikeRecord(cid, uri) + updater.updateActionMenu( + postKey = event.postKey, + newActionMenu = + ActionMenu.blueskyLike( accountKey = accountKey, - cacheDatabase = database, - ) { content -> - content.copy( - data = - content.data.copy( - viewer = - content.data.viewer?.copy( - like = AtUri(result.uri.atUri), - ) ?: ViewerState( - like = AtUri(result.uri.atUri), - ), - ), - ) - } - } - }.onFailure { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - ) { content -> - val count = - if (likedUri != null) { - (content.data.likeCount ?: 0) + 1 - } else { - (content.data.likeCount ?: 0) - 1 - }.coerceAtLeast(0) - content.copy( - data = - content.data.copy( - viewer = - content.data.viewer?.copy( - like = likedUri?.let { it1 -> AtUri(it1) }, - ) ?: ViewerState( - like = likedUri?.let { it1 -> AtUri(it1) }, - ), - likeCount = count, - ), - ) - } - } + postKey = event.postKey, + cid = cid, + uri = uri, + count = event.count + 1, + likedUri = response.uri.atUri, + ), + ) + } + } + + suspend fun bookmark( + event: PostEvent.Bluesky.Bookmark, + updater: DatabaseUpdater, + ) { + val cid = event.cid + val uri = event.uri + if (event.bookmarked) { + service + .deleteBookmark( + DeleteBookmarkRequest( + uri = AtUri(uri), + ), + ).requireResponse() + } else { + service + .createBookmark( + CreateBookmarkRequest( + uri = AtUri(uri), + cid = Cid(cid), + ), + ).requireResponse() } } @@ -762,245 +587,27 @@ internal class BlueskyDataSource( ), ) - override suspend fun deleteStatus(statusKey: MicroBlogKey) { - tryRun { - service.deleteRecord( - DeleteRecordRequest( - repo = Did(did = accountKey.id), - collection = Nsid("app.bsky.feed.post"), - rkey = RKey(statusKey.id.substringAfterLast('/')), - ), - ) - // delete status from cache - database.connect { - database.statusDao().delete( - statusKey = statusKey, - accountType = AccountType.Specific(accountKey), - ) - database.statusReferenceDao().delete(statusKey) - database.pagingTimelineDao().deleteStatus( - accountKey = accountKey, - statusKey = statusKey, - ) - } - } - } - - suspend fun unfollow(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - following = false, - ) - } - tryRun { - val user = - service - .getProfile(GetProfileQueryParams(actor = Did(did = userKey.id))) - .requireResponse() - - val followRepo = user.viewer?.following?.atUri - if (followRepo != null) { - service.deleteRecord( - DeleteRecordRequest( - repo = Did(did = accountKey.id), - collection = Nsid("app.bsky.graph.follow"), - rkey = RKey(followRepo.substringAfterLast('/')), - ), - ) - } - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - following = true, - ) - } - } - } - - suspend fun follow(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - following = true, - ) - } - tryRun { - service.createRecord( - CreateRecordRequest( - repo = Did(did = accountKey.id), - collection = Nsid("app.bsky.graph.follow"), - record = - app.bsky.graph - .Follow( - subject = Did(userKey.id), - createdAt = Clock.System.now(), - ).bskyJson(), - ), - ) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - following = false, - ) - } - } - } - - override suspend fun block(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - blocking = true, - ) - } - tryRun { - service.createRecord( - CreateRecordRequest( - repo = Did(did = accountKey.id), - collection = Nsid("app.bsky.graph.block"), - record = - app.bsky.graph - .Block( - subject = Did(userKey.id), - createdAt = Clock.System.now(), - ).bskyJson(), - ), - ) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - blocking = false, - ) - } - } - } - - suspend fun unblock(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - blocking = false, - ) - } - tryRun { - val user = - service - .getProfile(GetProfileQueryParams(actor = Did(did = userKey.id))) - .requireResponse() - - val blockRepo = user.viewer?.blocking?.atUri - if (blockRepo != null) { - service.deleteRecord( - DeleteRecordRequest( - repo = Did(did = accountKey.id), - collection = Nsid("app.bsky.graph.block"), - rkey = RKey(blockRepo.substringAfterLast('/')), - ), - ) - } - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - blocking = true, - ) - } - } - } - - override suspend fun mute(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - muted = true, - ) - } - tryRun { - service.muteActor(MuteActorRequest(actor = Did(did = userKey.id))) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - muted = false, - ) - } - } - } - - suspend fun unmute(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - muted = false, - ) - } - tryRun { - service.unmuteActor(UnmuteActorRequest(actor = Did(did = userKey.id))) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - muted = true, - ) - } - } - } - override fun searchStatus(query: String) = SearchStatusRemoteMediator( service, - database, accountKey, query, ) - override fun searchUser( - query: String, - pageSize: Int, - ): Flow> = - Pager( - config = pagingConfig, - ) { - SearchUserPagingSource( - service, - accountKey, - query, - ) - }.flow + override fun searchUser(query: String): RemoteLoader = + SearchUserPagingSource( + service, + accountKey, + query, + ) - override fun discoverUsers(pageSize: Int): Flow> = - Pager( - config = pagingConfig, - ) { - TrendsUserPagingSource( - service, - accountKey, - ) - }.flow + override fun discoverUsers(): RemoteLoader = + TrendsUserPagingSource( + service, + accountKey, + ) - override fun discoverHashtags(pageSize: Int): Flow> = + override fun discoverHashtags(): RemoteLoader = throw UnsupportedOperationException("Bluesky does not support discover hashtags") override fun discoverStatuses() = throw UnsupportedOperationException("Bluesky does not support discover statuses") @@ -1018,49 +625,6 @@ internal class BlueskyDataSource( language = ComposeConfig.Language(3), ) - override suspend fun follow( - userKey: MicroBlogKey, - relation: UiRelation, - ) { - when { - relation.following -> unfollow(userKey) - relation.blocking -> unblock(userKey) - else -> follow(userKey) - } - } - - override fun profileActions(): List = - listOf( - object : ProfileAction.Mute { - override suspend fun invoke( - userKey: MicroBlogKey, - relation: UiRelation, - ) { - if (relation.muted) { - unmute(userKey) - } else { - mute(userKey) - } - } - - override fun relationState(relation: UiRelation): Boolean = relation.muted - }, - object : ProfileAction.Block { - override suspend fun invoke( - userKey: MicroBlogKey, - relation: UiRelation, - ) { - if (relation.blocking) { - unblock(userKey) - } else { - block(userKey) - } - } - - override fun relationState(relation: UiRelation): Boolean = relation.blocking - }, - ) - private val myFeedsKey = "my_feeds_$accountKey" internal val feedLoader by lazy { @@ -1127,7 +691,6 @@ internal class BlueskyDataSource( FeedTimelineRemoteMediator( service = service, accountKey = accountKey, - database = database, uri = uri, ) @@ -1162,7 +725,6 @@ internal class BlueskyDataSource( ListTimelineRemoteMediator( service = service, accountKey = accountKey, - database = database, uri = listId, ) @@ -1198,23 +760,6 @@ internal class BlueskyDataSource( ) } - private val notificationMarkerKey: String - get() = "notificationBadgeCount_$accountKey" - - override fun notificationBadgeCount(): CacheData = - MemCacheable( - key = notificationMarkerKey, - fetchSource = { - val notifications = - service - .listNotifications( - params = ListNotificationsQueryParams(limit = 40), - ).requireResponse() - .notifications - notifications.count { !it.isRead } - }, - ) - override fun directMessageList(scope: CoroutineScope): Flow> = Pager( config = pagingConfig, @@ -1233,7 +778,7 @@ internal class BlueskyDataSource( .cachedIn(scope) .combine(credentialFlow) { paging, credential -> paging.map { - it.render(accountKey = accountKey, credential = credential, statusEvent = this) + it.render(accountKey = accountKey, credential = credential) } }.cachedIn(scope) @@ -1263,7 +808,6 @@ internal class BlueskyDataSource( it.render( accountKey = accountKey, credential = credential, - statusEvent = this, ) } }.cachedIn(scope) @@ -1294,7 +838,6 @@ internal class BlueskyDataSource( room?.render( accountKey = accountKey, credential = credential, - statusEvent = this, ) }.mapNotNull { it } }, @@ -1573,35 +1116,19 @@ internal class BlueskyDataSource( ).requireResponse() }.isSuccess - override fun following( - userKey: MicroBlogKey, - scope: CoroutineScope, - pageSize: Int, - ): Flow> = - Pager( - config = pagingConfig, - ) { - FollowingPagingSource( - service = service, - userKey = userKey, - accountKey = accountKey, - ) - }.flow.cachedIn(scope) + override fun following(userKey: MicroBlogKey): RemoteLoader = + FollowingPagingSource( + service = service, + userKey = userKey, + accountKey = accountKey, + ) - override fun fans( - userKey: MicroBlogKey, - scope: CoroutineScope, - pageSize: Int, - ): Flow> = - Pager( - config = pagingConfig, - ) { - FansPagingSource( - service = service, - userKey = userKey, - accountKey = accountKey, - ) - }.flow.cachedIn(scope) + override fun fans(userKey: MicroBlogKey): RemoteLoader = + FansPagingSource( + service = service, + userKey = userKey, + accountKey = accountKey, + ) override fun profileTabs(userKey: MicroBlogKey): ImmutableList = listOfNotNull( @@ -1611,7 +1138,6 @@ internal class BlueskyDataSource( UserTimelineRemoteMediator( service = service, accountKey = accountKey, - database = database, userKey = userKey, onlyMedia = false, withReplies = false, @@ -1623,7 +1149,6 @@ internal class BlueskyDataSource( UserTimelineRemoteMediator( service, accountKey, - database, userKey, withReplies = true, ), @@ -1636,7 +1161,6 @@ internal class BlueskyDataSource( UserLikesTimelineRemoteMediator( service, accountKey, - database, ), ) } else { @@ -1644,100 +1168,11 @@ internal class BlueskyDataSource( }, ).toPersistentList() - fun bookmarkTimeline(): BaseTimelineLoader = + fun bookmarkTimeline(): RemoteLoader = BookmarkTimelineRemoteMediator( service = service, accountKey = accountKey, - database = database, ) - - override fun bookmark( - statusKey: MicroBlogKey, - uri: String, - cid: String, - ) { - coroutineScope.launch { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - ) { content -> - content.copy( - data = - content.data.copy( - viewer = content.data.viewer?.copy(bookmarked = true), - ), - ) - } - tryRun { - service - .createBookmark( - CreateBookmarkRequest( - uri = AtUri(uri), - cid = Cid(cid), - ), - ).requireResponse() - }.onFailure { - it.printStackTrace() - // rollback - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - ) { content -> - content.copy( - data = - content.data.copy( - viewer = content.data.viewer?.copy(bookmarked = false), - ), - ) - } - } - } - } - - override fun unbookmark( - statusKey: MicroBlogKey, - uri: String, - ) { - coroutineScope.launch { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - ) { content -> - content.copy( - data = - content.data.copy( - viewer = content.data.viewer?.copy(bookmarked = false), - ), - ) - } - tryRun { - service - .deleteBookmark( - DeleteBookmarkRequest( - uri = AtUri(uri), - ), - ).requireResponse() - }.onFailure { - it.printStackTrace() - // rollback - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - ) { content -> - content.copy( - data = - content.data.copy( - viewer = content.data.viewer?.copy(bookmarked = true), - ), - ) - } - } - } - } } internal inline fun T.bskyJson(): JsonContent = bskyJson.encodeAsJsonContent(this) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyFeedLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyFeedLoader.kt index 933f46f00..6200706a7 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyFeedLoader.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyFeedLoader.kt @@ -9,9 +9,9 @@ import app.bsky.feed.GetFeedGeneratorsQueryParams import com.atproto.repo.CreateRecordRequest import com.atproto.repo.DeleteRecordRequest import com.atproto.repo.StrongRef -import dev.dimension.flare.data.datasource.microblog.list.ListLoader import dev.dimension.flare.data.datasource.microblog.list.ListMetaData import dev.dimension.flare.data.datasource.microblog.list.ListMetaDataType +import dev.dimension.flare.data.datasource.microblog.loader.ListLoader import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.bluesky.BlueskyService diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyListLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyListLoader.kt index 1de8baf62..1066c98b3 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyListLoader.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyListLoader.kt @@ -8,9 +8,9 @@ import com.atproto.repo.ApplyWritesRequestWriteUnion import com.atproto.repo.CreateRecordRequest import com.atproto.repo.PutRecordRequest import dev.dimension.flare.common.FileItem -import dev.dimension.flare.data.datasource.microblog.list.ListLoader import dev.dimension.flare.data.datasource.microblog.list.ListMetaData import dev.dimension.flare.data.datasource.microblog.list.ListMetaDataType +import dev.dimension.flare.data.datasource.microblog.loader.ListLoader import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.bluesky.BlueskyService diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyListMemberLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyListMemberLoader.kt index 19a904134..3585776e5 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyListMemberLoader.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyListMemberLoader.kt @@ -7,14 +7,13 @@ import app.bsky.graph.Listitem import com.atproto.repo.CreateRecordRequest import com.atproto.repo.DeleteRecordRequest import com.atproto.repo.ListRecordsQueryParams -import dev.dimension.flare.data.database.cache.mapper.toDbUser -import dev.dimension.flare.data.database.cache.model.DbUser -import dev.dimension.flare.data.datasource.microblog.list.ListMemberLoader +import dev.dimension.flare.data.datasource.microblog.loader.ListMemberLoader import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.bluesky.BlueskyService import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiList +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.mapper.render import sh.christian.ozone.api.AtUri import sh.christian.ozone.api.Did @@ -30,7 +29,7 @@ internal class BlueskyListMemberLoader( pageSize: Int, request: PagingRequest, listId: String, - ): PagingResult { + ): PagingResult { val cursor = when (request) { is PagingRequest.Append -> request.nextKey @@ -50,7 +49,7 @@ internal class BlueskyListMemberLoader( val users = response.items .map { - it.subject.toDbUser(accountKey.host) + it.subject.render(accountKey) } return PagingResult( data = users, @@ -61,7 +60,7 @@ internal class BlueskyListMemberLoader( override suspend fun addMember( listId: String, userKey: MicroBlogKey, - ): DbUser { + ): UiProfile { val user = service .getProfile(GetProfileQueryParams(actor = Did(did = userKey.id))) @@ -80,7 +79,7 @@ internal class BlueskyListMemberLoader( ).bskyJson(), ), ) - return user.toDbUser(accountKey.host) + return user.render(accountKey) } override suspend fun removeMember( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyLoader.kt new file mode 100644 index 000000000..3d3e84b09 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyLoader.kt @@ -0,0 +1,197 @@ +package dev.dimension.flare.data.datasource.bluesky + +import app.bsky.actor.GetProfileQueryParams +import app.bsky.feed.GetPostsQueryParams +import app.bsky.graph.MuteActorRequest +import app.bsky.graph.UnmuteActorRequest +import app.bsky.notification.ListNotificationsQueryParams +import com.atproto.identity.ResolveHandleQueryParams +import com.atproto.repo.CreateRecordRequest +import com.atproto.repo.DeleteRecordRequest +import dev.dimension.flare.data.datasource.microblog.loader.NotificationLoader +import dev.dimension.flare.data.datasource.microblog.loader.PostLoader +import dev.dimension.flare.data.datasource.microblog.loader.RelationActionType +import dev.dimension.flare.data.datasource.microblog.loader.RelationLoader +import dev.dimension.flare.data.datasource.microblog.loader.UserLoader +import dev.dimension.flare.data.network.bluesky.BlueskyService +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 dev.dimension.flare.ui.model.mapper.render +import kotlinx.collections.immutable.persistentListOf +import sh.christian.ozone.api.AtUri +import sh.christian.ozone.api.Did +import sh.christian.ozone.api.Handle +import sh.christian.ozone.api.Nsid +import sh.christian.ozone.api.RKey +import kotlin.time.Clock + +internal class BlueskyLoader( + val accountKey: MicroBlogKey, + private val service: BlueskyService, +) : NotificationLoader, + UserLoader, + PostLoader, + RelationLoader { + override val supportedTypes: Set = + setOf( + RelationActionType.Follow, + RelationActionType.Block, + RelationActionType.Mute, + ) + + override suspend fun userByHandleAndHost(uiHandle: UiHandle): UiProfile = + service + .getProfile(GetProfileQueryParams(actor = Handle(handle = uiHandle.normalizedRaw))) + .requireResponse() + .render(accountKey) + + override suspend fun userById(id: String): UiProfile = + service + .getProfile(GetProfileQueryParams(actor = Did(did = id))) + .requireResponse() + .render(accountKey) + + override suspend fun relation(userKey: MicroBlogKey): UiRelation { + val user = + service + .getProfile(GetProfileQueryParams(actor = Did(did = userKey.id))) + .requireResponse() + return UiRelation( + following = user.viewer?.following?.atUri != null, + isFans = user.viewer?.followedBy?.atUri != null, + blocking = user.viewer?.blockedBy ?: false, + muted = user.viewer?.muted ?: false, + ) + } + + override suspend fun status(statusKey: MicroBlogKey): UiTimelineV2 { + val isDid = statusKey.id.startsWith("at://did:") + if (isDid) { + return service + .getPosts( + GetPostsQueryParams( + persistentListOf(AtUri(statusKey.id)), + ), + ).requireResponse() + .posts + .first() + .render(accountKey) + } else { + // "at://${handle}/app.bsky.feed.post/${id}" + val handle = statusKey.id.substringAfter("at://").substringBefore("/") + val id = statusKey.id.substringAfterLast('/') + val did = + service + .resolveHandle(ResolveHandleQueryParams(Handle(handle))) + .requireResponse() + .did + val actualAtUri = AtUri("at://${did.did}/app.bsky.feed.post/$id") + return service + .getPosts( + GetPostsQueryParams( + persistentListOf(actualAtUri), + ), + ).requireResponse() + .posts + .first() + .render(accountKey) + } + } + + override suspend fun deleteStatus(statusKey: MicroBlogKey) { + service.deleteRecord( + DeleteRecordRequest( + repo = Did(did = accountKey.id), + collection = Nsid("app.bsky.feed.post"), + rkey = RKey(statusKey.id.substringAfterLast('/')), + ), + ) + } + + override suspend fun follow(userKey: MicroBlogKey) { + service.createRecord( + CreateRecordRequest( + repo = Did(did = accountKey.id), + collection = Nsid("app.bsky.graph.follow"), + record = + app.bsky.graph + .Follow( + subject = Did(userKey.id), + createdAt = Clock.System.now(), + ).bskyJson(), + ), + ) + } + + override suspend fun unfollow(userKey: MicroBlogKey) { + val user = + service + .getProfile(GetProfileQueryParams(actor = Did(did = userKey.id))) + .requireResponse() + + val followRepo = user.viewer?.following?.atUri + if (followRepo != null) { + service.deleteRecord( + DeleteRecordRequest( + repo = Did(did = accountKey.id), + collection = Nsid("app.bsky.graph.follow"), + rkey = RKey(followRepo.substringAfterLast('/')), + ), + ) + } + } + + override suspend fun block(userKey: MicroBlogKey) { + service.createRecord( + CreateRecordRequest( + repo = Did(did = accountKey.id), + collection = Nsid("app.bsky.graph.block"), + record = + app.bsky.graph + .Block( + subject = Did(userKey.id), + createdAt = Clock.System.now(), + ).bskyJson(), + ), + ) + } + + override suspend fun unblock(userKey: MicroBlogKey) { + val user = + service + .getProfile(GetProfileQueryParams(actor = Did(did = userKey.id))) + .requireResponse() + + val blockRepo = user.viewer?.blocking?.atUri + if (blockRepo != null) { + service.deleteRecord( + DeleteRecordRequest( + repo = Did(did = accountKey.id), + collection = Nsid("app.bsky.graph.block"), + rkey = RKey(blockRepo.substringAfterLast('/')), + ), + ) + } + } + + override suspend fun mute(userKey: MicroBlogKey) { + service.muteActor(MuteActorRequest(actor = Did(did = userKey.id))) + } + + override suspend fun unmute(userKey: MicroBlogKey) { + service.unmuteActor(UnmuteActorRequest(actor = Did(did = userKey.id))) + } + + override suspend fun notificationBadgeCount(): Int { + val notifications = + service + .listNotifications( + params = ListNotificationsQueryParams(limit = 40), + ).requireResponse() + .notifications + return notifications.count { !it.isRead } + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BookmarkTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BookmarkTimelineRemoteMediator.kt index 67dbbdf14..09d756c53 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BookmarkTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BookmarkTimelineRemoteMediator.kt @@ -2,29 +2,25 @@ package dev.dimension.flare.data.datasource.bluesky import androidx.paging.ExperimentalPagingApi import app.bsky.bookmark.GetBookmarksQueryParams -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDb -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.bluesky.BlueskyService import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class BookmarkTimelineRemoteMediator( private val service: BlueskyService, - database: CacheDatabase, private val accountKey: MicroBlogKey, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String = "bookmark_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { @@ -55,11 +51,7 @@ internal class BookmarkTimelineRemoteMediator( return PagingResult( endOfPaginationReached = response.bookmarks.isEmpty() || response.cursor == null, - data = - response.bookmarks.toDb( - accountKey = accountKey, - pagingKey = pagingKey, - ), + data = response.bookmarks.mapNotNull { it.render(accountKey) }, nextKey = response.cursor, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/FansPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/FansPagingSource.kt index 074a1c19f..e3d46503a 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/FansPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/FansPagingSource.kt @@ -1,8 +1,9 @@ package dev.dimension.flare.data.datasource.bluesky -import androidx.paging.PagingState import app.bsky.graph.GetFollowersQueryParams -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.bluesky.BlueskyService import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiProfile @@ -13,28 +14,44 @@ internal class FansPagingSource( private val service: BlueskyService, private val accountKey: MicroBlogKey, private val userKey: MicroBlogKey, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null - - override suspend fun doLoad(params: LoadParams): LoadResult { - val cursor = params.key - val limit = params.loadSize +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { val response = - service - .getFollowers( - params = - GetFollowersQueryParams( - actor = Did(userKey.id), - limit = limit.toLong(), - cursor = cursor, - ), - ).requireResponse() - return LoadResult.Page( - data = - response.followers.map { - it.render(accountKey = accountKey) - }, - prevKey = null, + when (request) { + is PagingRequest.Prepend -> { + return PagingResult( + endOfPaginationReached = true, + ) + } + + PagingRequest.Refresh -> + service + .getFollowers( + params = + GetFollowersQueryParams( + actor = Did(userKey.id), + limit = pageSize.toLong(), + ), + ).requireResponse() + + is PagingRequest.Append -> + service + .getFollowers( + params = + GetFollowersQueryParams( + actor = Did(userKey.id), + limit = pageSize.toLong(), + cursor = request.nextKey, + ), + ).requireResponse() + } + + return PagingResult( + endOfPaginationReached = response.cursor == null, + data = response.followers.map { it.render(accountKey = accountKey) }, nextKey = response.cursor, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/FeedTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/FeedTimelineRemoteMediator.kt index c9a575767..d6c996043 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/FeedTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/FeedTimelineRemoteMediator.kt @@ -2,31 +2,27 @@ package dev.dimension.flare.data.datasource.bluesky import androidx.paging.ExperimentalPagingApi import app.bsky.feed.GetFeedQueryParams -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.bluesky.BlueskyService import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render import sh.christian.ozone.api.AtUri @OptIn(ExperimentalPagingApi::class) internal class FeedTimelineRemoteMediator( private val service: BlueskyService, private val accountKey: MicroBlogKey, - private val database: CacheDatabase, private val uri: String, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey = "feed_timeline_$uri" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> @@ -60,11 +56,7 @@ internal class FeedTimelineRemoteMediator( return PagingResult( endOfPaginationReached = response.cursor == null, - data = - response.feed.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ), + data = response.feed.render(accountKey), nextKey = response.cursor, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/FollowingPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/FollowingPagingSource.kt index 2bcbcf649..4c39a9171 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/FollowingPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/FollowingPagingSource.kt @@ -1,8 +1,9 @@ package dev.dimension.flare.data.datasource.bluesky -import androidx.paging.PagingState import app.bsky.graph.GetFollowsQueryParams -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.bluesky.BlueskyService import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiProfile @@ -13,28 +14,44 @@ internal class FollowingPagingSource( private val service: BlueskyService, private val accountKey: MicroBlogKey, private val userKey: MicroBlogKey, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null - - override suspend fun doLoad(params: LoadParams): LoadResult { - val cursor = params.key - val limit = params.loadSize +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { val response = - service - .getFollows( - params = - GetFollowsQueryParams( - actor = Did(userKey.id), - limit = limit.toLong(), - cursor = cursor, - ), - ).requireResponse() - return LoadResult.Page( - data = - response.follows.map { - it.render(accountKey = accountKey) - }, - prevKey = null, + when (request) { + is PagingRequest.Prepend -> { + return PagingResult( + endOfPaginationReached = true, + ) + } + + PagingRequest.Refresh -> + service + .getFollows( + params = + GetFollowsQueryParams( + actor = Did(userKey.id), + limit = pageSize.toLong(), + ), + ).requireResponse() + + is PagingRequest.Append -> + service + .getFollows( + params = + GetFollowsQueryParams( + actor = Did(userKey.id), + limit = pageSize.toLong(), + cursor = request.nextKey, + ), + ).requireResponse() + } + + return PagingResult( + endOfPaginationReached = response.cursor == null, + data = response.follows.map { it.render(accountKey = accountKey) }, nextKey = response.cursor, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/HomeTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/HomeTimelineRemoteMediator.kt index 24e5eaffb..241150c37 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/HomeTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/HomeTimelineRemoteMediator.kt @@ -2,36 +2,25 @@ package dev.dimension.flare.data.datasource.bluesky import androidx.paging.ExperimentalPagingApi import app.bsky.feed.GetTimelineQueryParams -import dev.dimension.flare.common.InAppNotification -import dev.dimension.flare.common.Message -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.bluesky.BlueskyService -import dev.dimension.flare.data.repository.LoginExpiredException import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class HomeTimelineRemoteMediator( private val service: BlueskyService, private val accountKey: MicroBlogKey, - private val database: CacheDatabase, - private val inAppNotification: InAppNotification, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String = "home_$accountKey" - override suspend fun initialize(): InitializeAction = InitializeAction.SKIP_INITIAL_REFRESH - - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { is PagingRequest.Prepend -> return PagingResult( @@ -61,24 +50,8 @@ internal class HomeTimelineRemoteMediator( ) return PagingResult( endOfPaginationReached = response.cursor == null, - data = - response.feed.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ), + data = response.feed.render(accountKey), nextKey = response.cursor, ) } - - override fun onError(e: Throwable) { - if (e is LoginExpiredException) { - inAppNotification.onError( - Message.LoginExpired, - LoginExpiredException( - accountKey = accountKey, - platformType = PlatformType.Bluesky, - ), - ) - } - } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/ListTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/ListTimelineRemoteMediator.kt index ca81b15fa..26d4a32ea 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/ListTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/ListTimelineRemoteMediator.kt @@ -2,31 +2,27 @@ package dev.dimension.flare.data.datasource.bluesky import androidx.paging.ExperimentalPagingApi import app.bsky.feed.GetListFeedQueryParams -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.bluesky.BlueskyService import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render import sh.christian.ozone.api.AtUri @OptIn(ExperimentalPagingApi::class) internal class ListTimelineRemoteMediator( private val service: BlueskyService, private val accountKey: MicroBlogKey, - private val database: CacheDatabase, private val uri: String, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey = "list_timeline_${uri}_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> @@ -60,11 +56,7 @@ internal class ListTimelineRemoteMediator( return PagingResult( endOfPaginationReached = response.cursor == null, - data = - response.feed.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ), + data = response.feed.render(accountKey), nextKey = response.cursor, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/NotificationRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/NotificationRemoteMediator.kt index 0ccc8f05d..0d7eb62b2 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/NotificationRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/NotificationRemoteMediator.kt @@ -7,14 +7,13 @@ import app.bsky.feed.Repost import app.bsky.notification.ListNotificationsNotificationReason import app.bsky.notification.ListNotificationsQueryParams import app.bsky.notification.UpdateSeenRequest -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDb -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.bluesky.BlueskyService import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import kotlin.time.Clock @@ -23,21 +22,18 @@ import kotlin.time.Clock internal class NotificationRemoteMediator( private val service: BlueskyService, private val accountKey: MicroBlogKey, - private val database: CacheDatabase, private val onClearMarker: () -> Unit, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String = buildString { append("notification_") append(accountKey.toString()) } - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { @@ -116,12 +112,7 @@ internal class NotificationRemoteMediator( .toImmutableMap() return PagingResult( endOfPaginationReached = response.cursor == null, - data = - response.notifications.toDb( - accountKey = accountKey, - pagingKey = pagingKey, - references = references, - ), + data = response.notifications.render(accountKey, references), nextKey = response.cursor, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/SearchStatusRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/SearchStatusRemoteMediator.kt index c43ce26bb..775bb14f8 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/SearchStatusRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/SearchStatusRemoteMediator.kt @@ -2,24 +2,20 @@ package dev.dimension.flare.data.datasource.bluesky import androidx.paging.ExperimentalPagingApi import app.bsky.feed.SearchPostsQueryParams -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDb -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.bluesky.BlueskyService import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class SearchStatusRemoteMediator( private val service: BlueskyService, - private val database: CacheDatabase, private val accountKey: MicroBlogKey, private val query: String, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String = buildString { append("search_") @@ -27,10 +23,10 @@ internal class SearchStatusRemoteMediator( append(accountKey.toString()) } - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { is PagingRequest.Prepend -> { @@ -60,11 +56,7 @@ internal class SearchStatusRemoteMediator( return PagingResult( endOfPaginationReached = response.cursor == null, - data = - response.posts.toDb( - accountKey = accountKey, - pagingKey = pagingKey, - ), + data = response.posts.map { it.render(accountKey) }, nextKey = response.cursor, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/SearchUserPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/SearchUserPagingSource.kt index 93dad5e1c..17e22f083 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/SearchUserPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/SearchUserPagingSource.kt @@ -1,8 +1,9 @@ package dev.dimension.flare.data.datasource.bluesky -import androidx.paging.PagingState import app.bsky.actor.SearchActorsQueryParams -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.bluesky.BlueskyService import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiProfile @@ -12,20 +13,43 @@ internal class SearchUserPagingSource( private val service: BlueskyService, private val accountKey: MicroBlogKey, private val query: String, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + val response = + when (request) { + is PagingRequest.Prepend -> { + return PagingResult( + endOfPaginationReached = true, + ) + } - override suspend fun doLoad(params: LoadParams): LoadResult { - service - .searchActors( - SearchActorsQueryParams(q = query, limit = params.loadSize.toLong(), cursor = params.key), - ).requireResponse() - .let { - return LoadResult.Page( - data = it.actors.map { it.render(accountKey) }, - prevKey = null, - nextKey = it.cursor, - ) + PagingRequest.Refresh -> + service + .searchActors( + SearchActorsQueryParams( + q = query, + limit = pageSize.toLong(), + ), + ).requireResponse() + + is PagingRequest.Append -> + service + .searchActors( + SearchActorsQueryParams( + q = query, + limit = pageSize.toLong(), + cursor = request.nextKey, + ), + ).requireResponse() } + + return PagingResult( + endOfPaginationReached = response.cursor == null, + data = response.actors.map { it.render(accountKey) }, + nextKey = response.cursor, + ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/StatusDetailRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/StatusDetailRemoteMediator.kt index 9dd7b3e6a..8c860a7b8 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/StatusDetailRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/StatusDetailRemoteMediator.kt @@ -11,16 +11,13 @@ import app.bsky.feed.ReplyRefRootUnion import app.bsky.feed.ThreadViewPost import app.bsky.feed.ThreadViewPostParentUnion import app.bsky.feed.ThreadViewPostReplieUnion -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.bluesky.BlueskyService -import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.firstOrNull import sh.christian.ozone.api.AtUri @@ -30,11 +27,8 @@ internal class StatusDetailRemoteMediator( private val statusKey: MicroBlogKey, private val service: BlueskyService, private val accountKey: MicroBlogKey, - private val database: CacheDatabase, private val statusOnly: Boolean, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String = buildString { append("status_detail_") @@ -46,10 +40,10 @@ internal class StatusDetailRemoteMediator( append(accountKey.toString()) } - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val result = when (request) { is PagingRequest.Append -> { @@ -138,23 +132,6 @@ internal class StatusDetailRemoteMediator( ) } PagingRequest.Refresh -> { - if (!database.pagingTimelineDao().existsPaging(accountKey, pagingKey)) { - database.statusDao().get(statusKey, AccountType.Specific(accountKey)).firstOrNull()?.let { - database - .pagingTimelineDao() - .insertAll( - listOf( - DbPagingTimeline( - accountType = AccountType.Specific(accountKey), - statusKey = statusKey, - pagingKey = pagingKey, - sortId = 0, - ), - ), - ) - } - } - val current = service .getPosts( @@ -171,13 +148,7 @@ internal class StatusDetailRemoteMediator( val shouldLoadMore = !(request is PagingRequest.Append || statusOnly) return PagingResult( endOfPaginationReached = !shouldLoadMore, - data = - result.toDbPagingTimeline( - accountKey, - pagingKey, - ) { - -result.indexOf(it).toLong() - }, + data = result.render(accountKey), nextKey = if (shouldLoadMore) pagingKey else null, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/TrendsUserPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/TrendsUserPagingSource.kt index bafea783d..6f8ce6f61 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/TrendsUserPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/TrendsUserPagingSource.kt @@ -1,8 +1,9 @@ package dev.dimension.flare.data.datasource.bluesky -import androidx.paging.PagingState import app.bsky.actor.GetSuggestionsQueryParams -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.bluesky.BlueskyService import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiProfile @@ -11,17 +12,40 @@ import dev.dimension.flare.ui.model.mapper.render internal class TrendsUserPagingSource( private val service: BlueskyService, private val accountKey: MicroBlogKey, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null - - override suspend fun doLoad(params: LoadParams): LoadResult { +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { val response = - service - .getSuggestions(GetSuggestionsQueryParams(limit = params.loadSize.toLong(), cursor = params.key)) - .requireResponse() - return LoadResult.Page( + when (request) { + is PagingRequest.Prepend -> { + return PagingResult( + endOfPaginationReached = true, + ) + } + + PagingRequest.Refresh -> + service + .getSuggestions( + GetSuggestionsQueryParams( + limit = pageSize.toLong(), + ), + ).requireResponse() + + is PagingRequest.Append -> + service + .getSuggestions( + GetSuggestionsQueryParams( + limit = pageSize.toLong(), + cursor = request.nextKey, + ), + ).requireResponse() + } + + return PagingResult( + endOfPaginationReached = response.cursor == null, data = response.actors.map { it.render(accountKey) }, - prevKey = null, nextKey = response.cursor, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/UserLikesTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/UserLikesTimelineRemoteMediator.kt index 76041f6c0..dd8c2d349 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/UserLikesTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/UserLikesTimelineRemoteMediator.kt @@ -2,30 +2,26 @@ package dev.dimension.flare.data.datasource.bluesky import androidx.paging.ExperimentalPagingApi import app.bsky.feed.GetActorLikesQueryParams -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.bluesky.BlueskyService import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render import sh.christian.ozone.api.Did @OptIn(ExperimentalPagingApi::class) internal class UserLikesTimelineRemoteMediator( private val service: BlueskyService, private val accountKey: MicroBlogKey, - private val database: CacheDatabase, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey = "user_timeline_likes_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> @@ -59,11 +55,7 @@ internal class UserLikesTimelineRemoteMediator( return PagingResult( endOfPaginationReached = response.cursor == null, - data = - response.feed.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ), + data = response.feed.render(accountKey), nextKey = response.cursor, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/UserTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/UserTimelineRemoteMediator.kt index 593e16555..1e71894fe 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/UserTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/UserTimelineRemoteMediator.kt @@ -3,27 +3,23 @@ package dev.dimension.flare.data.datasource.bluesky import androidx.paging.ExperimentalPagingApi import app.bsky.feed.GetAuthorFeedFilter import app.bsky.feed.GetAuthorFeedQueryParams -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.bluesky.BlueskyService import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render import sh.christian.ozone.api.Did @OptIn(ExperimentalPagingApi::class) internal class UserTimelineRemoteMediator( private val service: BlueskyService, private val accountKey: MicroBlogKey, - private val database: CacheDatabase, private val userKey: MicroBlogKey, private val onlyMedia: Boolean = false, private val withReplies: Boolean = false, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey = buildString { append("user_timeline") @@ -37,10 +33,10 @@ internal class UserTimelineRemoteMediator( append(userKey.toString()) } - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val filter = when { onlyMedia -> GetAuthorFeedFilter.PostsWithMedia @@ -84,11 +80,7 @@ internal class UserTimelineRemoteMediator( return PagingResult( endOfPaginationReached = response.cursor == null, - data = - response.feed.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ), + data = response.feed.render(accountKey), nextKey = response.cursor, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestDiscoverStatusPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestDiscoverStatusPagingSource.kt index 894254ca8..4d3fc7000 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestDiscoverStatusPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestDiscoverStatusPagingSource.kt @@ -1,34 +1,35 @@ package dev.dimension.flare.data.datasource.guest.mastodon -import SnowflakeIdGenerator -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.mastodon.api.TrendsResources -import dev.dimension.flare.ui.model.UiTimeline -import dev.dimension.flare.ui.model.mapper.renderGuest +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render internal class GuestDiscoverStatusPagingSource( private val service: TrendsResources, private val host: String, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): Int? = null +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend) { + return PagingResult(endOfPaginationReached = true) + } + val offset = + when (request) { + PagingRequest.Refresh -> 0 + is PagingRequest.Append -> request.nextKey.toIntOrNull() ?: 0 + is PagingRequest.Prepend -> 0 + } - override suspend fun doLoad(params: LoadParams): LoadResult { - val result = - service.trendsStatuses( - limit = params.loadSize, - offset = params.key, - ) - - return LoadResult.Page( - data = - result.map { - it - .renderGuest(host = host) - .copy(dbKey = "guest_${SnowflakeIdGenerator.nextId()}") - }, - prevKey = null, - nextKey = result.size + (params.key ?: 0), + val result = service.trendsStatuses(limit = pageSize, offset = offset) + return PagingResult( + endOfPaginationReached = result.size < pageSize, + data = result.map { it.render(host = host, accountKey = null) }, + nextKey = (offset + result.size).toString(), ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestMastodonDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestMastodonDataSource.kt index 8e93aba32..517d65772 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestMastodonDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestMastodonDataSource.kt @@ -1,236 +1,183 @@ package dev.dimension.flare.data.datasource.guest.mastodon -import androidx.paging.Pager -import androidx.paging.PagingData -import androidx.paging.cachedIn -import dev.dimension.flare.common.CacheData -import dev.dimension.flare.common.Cacheable -import dev.dimension.flare.common.MemCacheable -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbUser -import dev.dimension.flare.data.database.cache.model.UserContent import dev.dimension.flare.data.datasource.mastodon.MastodonFansPagingSource import dev.dimension.flare.data.datasource.mastodon.MastodonFollowingPagingSource import dev.dimension.flare.data.datasource.mastodon.SearchUserPagingSource import dev.dimension.flare.data.datasource.mastodon.TrendHashtagPagingSource +import dev.dimension.flare.data.datasource.microblog.DatabaseUpdater import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource +import dev.dimension.flare.data.datasource.microblog.PostEvent import dev.dimension.flare.data.datasource.microblog.ProfileTab -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelinePagingSourceFactory -import dev.dimension.flare.data.datasource.microblog.pagingConfig +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.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.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.mastodon.GuestMastodonService +import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.model.PlatformType import dev.dimension.flare.ui.model.UiHashtag import dev.dimension.flare.ui.model.UiProfile -import dev.dimension.flare.ui.model.UiTimeline -import dev.dimension.flare.ui.model.mapper.render -import dev.dimension.flare.ui.model.mapper.renderGuest +import dev.dimension.flare.ui.model.UiTimelineV2 import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.mapNotNull import org.koin.core.component.KoinComponent -import org.koin.core.component.inject internal class GuestMastodonDataSource( private val host: String, private val locale: String, ) : MicroblogDataSource, + UserDataSource, + RelationDataSource, + PostDataSource, + PostEventHandler.Handler, KoinComponent { private val service by lazy { GuestMastodonService("https://$host/", locale) } - private val database: CacheDatabase by inject() - override fun homeTimeline() = - BaseTimelinePagingSourceFactory { - GuestTimelinePagingSource(host = host, service = service) - } + private val loader by lazy { + GuestMastodonLoader( + host = host, + service = service, + ) + } - override fun userByAcct(acct: String): CacheData { - val (name, host) = MicroBlogKey.valueOf(acct) - return Cacheable( - fetchSource = { - val user = - service - .lookupUserByAcct("$name@$host") - ?.toDbUser(host) ?: throw Exception("User not found") - database.userDao().insert(user) - }, - cacheSource = { - database - .userDao() - .findByHandleAndHost(name, host, PlatformType.Mastodon) - .distinctUntilChanged() - .mapNotNull { - val content = it?.content - if (content is UserContent.Mastodon) { - content.data.render(null, host = host) - } else { - null - } - } - }, + override val userHandler by lazy { + UserHandler( + host = host, + loader = loader, ) } - override fun userById(id: String): CacheData { - val userKey = MicroBlogKey(id, host) - return Cacheable( - fetchSource = { - val user = service.lookupUser(id).toDbUser(host) - database.userDao().insert(user) - }, - cacheSource = { - database - .userDao() - .findByKey(userKey) - .distinctUntilChanged() - .mapNotNull { - val content = it?.content - if (content is UserContent.Mastodon) { - content.data.render(null, host = host) - } else { - null - } - } - }, + override val relationHandler by lazy { + RelationHandler( + dataSource = loader, + accountType = AccountType.Guest, ) } + override val postHandler by lazy { + PostHandler( + accountType = AccountType.Guest, + loader = loader, + ) + } + + override val postEventHandler by lazy { + PostEventHandler( + accountType = AccountType.Guest, + handler = this, + ) + } + + override val supportedRelationTypes: Set + get() = loader.supportedTypes + + override suspend fun handle( + event: PostEvent, + updater: DatabaseUpdater, + ): Unit = throw UnsupportedOperationException("Guest Mastodon data source does not support post events") + + override fun homeTimeline(): RemoteLoader = GuestTimelinePagingSource(service = service, host = host) + override fun userTimeline( userKey: MicroBlogKey, mediaOnly: Boolean, - ) = BaseTimelinePagingSourceFactory { + ): RemoteLoader = GuestUserTimelinePagingSource( + service = service, host = host, userId = userKey.id, onlyMedia = mediaOnly, + ) + + override fun context(statusKey: MicroBlogKey): RemoteLoader = + GuestStatusDetailPagingSource( service = service, + host = host, + statusKey = statusKey, + statusOnly = false, ) - } - override fun context(statusKey: MicroBlogKey) = - BaseTimelinePagingSourceFactory { - GuestStatusDetailPagingSource( - host = host, - statusKey = statusKey, - statusOnly = false, - service = service, - ) - } + override fun searchStatus(query: String): RemoteLoader = + GuestSearchStatusPagingSource( + service = service, + host = host, + query = query, + ) - override fun status(statusKey: MicroBlogKey): CacheData { - val pagingKey = "status_only_$statusKey" - return MemCacheable( - key = pagingKey, - fetchSource = { - service - .lookupStatus( - statusKey.id, - ).renderGuest(host) - }, + override fun searchUser(query: String): RemoteLoader = + SearchUserPagingSource( + service = service, + host = host, + accountKey = null, + query = query, ) - } - override fun searchStatus(query: String) = - BaseTimelinePagingSourceFactory { - GuestSearchStatusPagingSource(host = host, query = query, service = service) + override fun discoverUsers(): RemoteLoader = + object : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult = + PagingResult( + endOfPaginationReached = true, + data = emptyList(), + ) } - override fun searchUser( - query: String, - pageSize: Int, - ): Flow> = - Pager( - config = pagingConfig, - ) { - SearchUserPagingSource( - service = service, - host = host, - accountKey = null, - query, - ) - }.flow - - override fun discoverUsers(pageSize: Int): Flow> { - // not supported - throw UnsupportedOperationException("Discover users not supported") - } + override fun discoverStatuses(): RemoteLoader = + GuestDiscoverStatusPagingSource( + service = service, + host = host, + ) - override fun discoverStatuses() = TODO() -// Pager(pagingConfig) { -// GuestDiscoverStatusPagingSource(host = host, service = service) -// }.flow.cachedIn(scope) - - override fun discoverHashtags(pageSize: Int): Flow> = - Pager( - config = pagingConfig, - ) { - TrendHashtagPagingSource( - service, - ) - }.flow - - override fun following( - userKey: MicroBlogKey, - scope: CoroutineScope, - pageSize: Int, - ): Flow> = - Pager( - config = pagingConfig, - ) { - MastodonFollowingPagingSource( - service = service, - host = host, - userKey = userKey, - accountKey = null, - ) - }.flow.cachedIn(scope) - - override fun fans( - userKey: MicroBlogKey, - scope: CoroutineScope, - pageSize: Int, - ): Flow> = - Pager( - config = pagingConfig, - ) { - MastodonFansPagingSource( - service = service, - host = host, - userKey = userKey, - accountKey = null, - ) - }.flow.cachedIn(scope) + override fun discoverHashtags(): RemoteLoader = TrendHashtagPagingSource(service) + + override fun following(userKey: MicroBlogKey): RemoteLoader = + MastodonFollowingPagingSource( + service = service, + accountKey = null, + host = host, + userKey = userKey, + ) + + override fun fans(userKey: MicroBlogKey): RemoteLoader = + MastodonFansPagingSource( + service = service, + accountKey = null, + host = host, + userKey = userKey, + ) override fun profileTabs(userKey: MicroBlogKey): ImmutableList = persistentListOf( ProfileTab.Timeline( type = ProfileTab.Timeline.Type.Status, loader = - BaseTimelinePagingSourceFactory { - GuestUserTimelinePagingSource( - host = host, - userId = userKey.id, - service = service, - withPinned = true, - ) - }, + GuestUserTimelinePagingSource( + service = service, + host = host, + userId = userKey.id, + withPinned = true, + ), ), ProfileTab.Timeline( type = ProfileTab.Timeline.Type.StatusWithReplies, loader = - BaseTimelinePagingSourceFactory { - GuestUserTimelinePagingSource( - host = host, - userId = userKey.id, - withReply = true, - service = service, - ) - }, + GuestUserTimelinePagingSource( + service = service, + host = host, + userId = userKey.id, + withReply = true, + ), ), ProfileTab.Media, ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestMastodonLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestMastodonLoader.kt new file mode 100644 index 000000000..7a46ef71a --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestMastodonLoader.kt @@ -0,0 +1,69 @@ +package dev.dimension.flare.data.datasource.guest.mastodon + +import dev.dimension.flare.data.datasource.microblog.loader.PostLoader +import dev.dimension.flare.data.datasource.microblog.loader.RelationActionType +import dev.dimension.flare.data.datasource.microblog.loader.RelationLoader +import dev.dimension.flare.data.datasource.microblog.loader.UserLoader +import dev.dimension.flare.data.network.mastodon.GuestMastodonService +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 dev.dimension.flare.ui.model.mapper.render + +internal class GuestMastodonLoader( + private val host: String, + private val service: GuestMastodonService, +) : UserLoader, + RelationLoader, + PostLoader { + override val supportedTypes: Set = RelationActionType.entries.toSet() + + override suspend fun userByHandleAndHost(uiHandle: UiHandle): UiProfile = + service + .lookupUserByAcct("${uiHandle.normalizedRaw}@${uiHandle.normalizedHost}") + ?.render( + accountKey = null, + host = host, + ) ?: throw Exception("User not found") + + override suspend fun userById(id: String): UiProfile = + service + .lookupUser(id) + .render( + accountKey = null, + host = host, + ) + + override suspend fun relation(userKey: MicroBlogKey): UiRelation = UiRelation() + + override suspend fun status(statusKey: MicroBlogKey): UiTimelineV2 = + service + .lookupStatus(statusKey.id) + .render( + accountKey = null, + host = host, + ) + + override suspend fun deleteStatus(statusKey: MicroBlogKey): Unit = + throw UnsupportedOperationException("Guest Mastodon data source does not support delete status") + + override suspend fun follow(userKey: MicroBlogKey): Unit = + throw UnsupportedOperationException("Guest Mastodon data source does not support follow") + + override suspend fun unfollow(userKey: MicroBlogKey): Unit = + throw UnsupportedOperationException("Guest Mastodon data source does not support unfollow") + + override suspend fun block(userKey: MicroBlogKey): Unit = + throw UnsupportedOperationException("Guest Mastodon data source does not support block") + + override suspend fun unblock(userKey: MicroBlogKey): Unit = + throw UnsupportedOperationException("Guest Mastodon data source does not support unblock") + + override suspend fun mute(userKey: MicroBlogKey): Unit = + throw UnsupportedOperationException("Guest Mastodon data source does not support mute") + + override suspend fun unmute(userKey: MicroBlogKey): Unit = + throw UnsupportedOperationException("Guest Mastodon data source does not support unmute") +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestSearchStatusPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestSearchStatusPagingSource.kt index 3a7b7282e..145a3b67f 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestSearchStatusPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestSearchStatusPagingSource.kt @@ -1,47 +1,48 @@ package dev.dimension.flare.data.datasource.guest.mastodon -import SnowflakeIdGenerator -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.mastodon.GuestMastodonService -import dev.dimension.flare.ui.model.UiTimeline -import dev.dimension.flare.ui.model.mapper.renderGuest +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render internal class GuestSearchStatusPagingSource( private val service: GuestMastodonService, private val host: String, private val query: String, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend) { + return PagingResult(endOfPaginationReached = true) + } - override suspend fun doLoad(params: LoadParams): LoadResult { + val maxId = (request as? PagingRequest.Append)?.nextKey val result = if (query.startsWith("#")) { service.hashtagTimeline( hashtag = query.removePrefix("#"), - limit = params.loadSize, - max_id = params.key, + limit = pageSize, + max_id = maxId, ) } else { service .searchV2( query = query, - limit = params.loadSize, + limit = pageSize, type = "statuses", - max_id = params.key, + max_id = maxId, ).statuses } - return LoadResult.Page( - data = - result - ?.map { - it - .renderGuest(host = host) - .copy(dbKey = "guest_${SnowflakeIdGenerator.nextId()}") - }.orEmpty(), - prevKey = null, - nextKey = result?.lastOrNull()?.id, + val data = result.orEmpty() + return PagingResult( + endOfPaginationReached = data.isEmpty(), + data = data.map { it.render(host = host, accountKey = null) }, + nextKey = data.lastOrNull()?.id, ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestStatusDetailPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestStatusDetailPagingSource.kt index c1b41024a..aadcf5bfe 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestStatusDetailPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestStatusDetailPagingSource.kt @@ -1,50 +1,39 @@ package dev.dimension.flare.data.datasource.guest.mastodon -import SnowflakeIdGenerator -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.mastodon.GuestMastodonService import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiTimeline -import dev.dimension.flare.ui.model.mapper.renderGuest +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render internal class GuestStatusDetailPagingSource( private val service: GuestMastodonService, private val host: String, private val statusKey: MicroBlogKey, private val statusOnly: Boolean, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): Int? = null +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend || request is PagingRequest.Append) { + return PagingResult(endOfPaginationReached = true) + } - override suspend fun doLoad(params: LoadParams): LoadResult { val result = if (statusOnly) { - val current = - service.lookupStatus( - statusKey.id, - ) - listOf(current) + listOf(service.lookupStatus(statusKey.id)) } else { - val context = - service.context( - statusKey.id, - ) - val current = - service.lookupStatus( - statusKey.id, - ) + val context = service.context(statusKey.id) + val current = service.lookupStatus(statusKey.id) context.ancestors.orEmpty() + listOf(current) + context.descendants.orEmpty() } - return LoadResult.Page( - data = - result.map { - it - .renderGuest(host = host) - .copy(dbKey = "guest_${SnowflakeIdGenerator.nextId()}") - }, - prevKey = null, - nextKey = null, + return PagingResult( + endOfPaginationReached = true, + data = result.map { it.render(host = host, accountKey = null) }, ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestTimelinePagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestTimelinePagingSource.kt index 544d978bd..98a00483a 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestTimelinePagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestTimelinePagingSource.kt @@ -1,31 +1,34 @@ package dev.dimension.flare.data.datasource.guest.mastodon -import SnowflakeIdGenerator -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.mastodon.api.TrendsResources -import dev.dimension.flare.ui.model.UiTimeline -import dev.dimension.flare.ui.model.mapper.renderGuest +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render internal class GuestTimelinePagingSource( private val service: TrendsResources, private val host: String, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): Int? = null +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + val offset = + when (request) { + PagingRequest.Refresh -> 0 + is PagingRequest.Append -> request.nextKey.toIntOrNull() ?: 0 + is PagingRequest.Prepend -> { + return PagingResult(endOfPaginationReached = true) + } + } - override suspend fun doLoad(params: LoadParams): LoadResult { - val offset = params.key ?: 0 - val limit = params.loadSize - val statuses = service.trendsStatuses(limit = limit, offset = offset).distinctBy { it.id } - return LoadResult.Page( - data = - statuses.map { - it - .renderGuest(host = host) - .copy(dbKey = "guest_${SnowflakeIdGenerator.nextId()}") - }, - prevKey = null, - nextKey = offset + limit, + val statuses = service.trendsStatuses(limit = pageSize, offset = offset).distinctBy { it.id } + return PagingResult( + endOfPaginationReached = statuses.size < pageSize, + data = statuses.map { it.render(host = host, accountKey = null) }, + nextKey = (offset + pageSize).toString(), ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestUserTimelinePagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestUserTimelinePagingSource.kt index d6db53e80..f7d5db7ad 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestUserTimelinePagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/guest/mastodon/GuestUserTimelinePagingSource.kt @@ -1,10 +1,11 @@ package dev.dimension.flare.data.datasource.guest.mastodon -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.mastodon.api.TimelineResources -import dev.dimension.flare.ui.model.UiTimeline -import dev.dimension.flare.ui.model.mapper.renderGuest +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render internal class GuestUserTimelinePagingSource( private val service: TimelineResources, @@ -13,14 +14,18 @@ internal class GuestUserTimelinePagingSource( private val withReply: Boolean = false, private val onlyMedia: Boolean = false, private val withPinned: Boolean = false, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + val maxId = (request as? PagingRequest.Append)?.nextKey + if (request is PagingRequest.Prepend) { + return PagingResult(endOfPaginationReached = true) + } - override suspend fun doLoad(params: LoadParams): LoadResult { - val maxId = params.key - val limit = params.loadSize val pinned = - if (withPinned && maxId == null) { + if (withPinned && request == PagingRequest.Refresh) { service.userTimeline( user_id = userId, pinned = true, @@ -28,11 +33,12 @@ internal class GuestUserTimelinePagingSource( } else { emptyList() } + val statuses = service .userTimeline( user_id = userId, - limit = limit, + limit = pageSize, max_id = maxId, only_media = onlyMedia, exclude_replies = !withReply, @@ -43,17 +49,11 @@ internal class GuestUserTimelinePagingSource( } else { it } - }.distinctBy { - it.id - } - return LoadResult.Page( - data = - statuses.map { - it - .renderGuest(host = host) - .copy(dbKey = "guest_${SnowflakeIdGenerator.nextId()}") - }, - prevKey = null, + }.distinctBy { it.id } + + return PagingResult( + endOfPaginationReached = statuses.isEmpty(), + data = statuses.map { it.render(host = host, accountKey = null) }, nextKey = statuses.lastOrNull()?.id, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/BookmarkTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/BookmarkTimelineRemoteMediator.kt index dcde86c39..80166fada 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/BookmarkTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/BookmarkTimelineRemoteMediator.kt @@ -1,30 +1,25 @@ package dev.dimension.flare.data.datasource.mastodon -import SnowflakeIdGenerator import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.mastodon.MastodonService import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class BookmarkTimelineRemoteMediator( private val service: MastodonService, - database: CacheDatabase, private val accountKey: MicroBlogKey, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String = "bookmark_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { @@ -50,14 +45,7 @@ internal class BookmarkTimelineRemoteMediator( return PagingResult( endOfPaginationReached = response.isEmpty() || response.next == null, - data = - response.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - sortIdProvider = { - -SnowflakeIdGenerator.nextId() - }, - ), + data = response.render(accountKey), nextKey = response.next, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/DiscoverStatusRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/DiscoverStatusRemoteMediator.kt index ce429243c..fe5200a00 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/DiscoverStatusRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/DiscoverStatusRemoteMediator.kt @@ -1,29 +1,25 @@ package dev.dimension.flare.data.datasource.mastodon import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.mastodon.MastodonService import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class DiscoverStatusRemoteMediator( private val service: MastodonService, - database: CacheDatabase, private val accountKey: MicroBlogKey, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String = "discover_status_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { @@ -39,11 +35,7 @@ internal class DiscoverStatusRemoteMediator( return PagingResult( endOfPaginationReached = true, - data = - response.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ), + data = response.render(accountKey), ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/FavouriteTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/FavouriteTimelineRemoteMediator.kt index 829afb139..2981fba5b 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/FavouriteTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/FavouriteTimelineRemoteMediator.kt @@ -1,29 +1,25 @@ package dev.dimension.flare.data.datasource.mastodon import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.mastodon.MastodonService import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class FavouriteTimelineRemoteMediator( private val service: MastodonService, - database: CacheDatabase, private val accountKey: MicroBlogKey, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String = "favourite_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { @@ -49,11 +45,7 @@ internal class FavouriteTimelineRemoteMediator( return PagingResult( endOfPaginationReached = response.isEmpty() || response.next == null, - data = - response.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ), + data = response.render(accountKey), nextKey = response.next, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/HomeTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/HomeTimelineRemoteMediator.kt index 2177a747b..cd29da393 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/HomeTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/HomeTimelineRemoteMediator.kt @@ -1,31 +1,25 @@ package dev.dimension.flare.data.datasource.mastodon import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.mastodon.MastodonService import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class HomeTimelineRemoteMediator( private val service: MastodonService, - database: CacheDatabase, private val accountKey: MicroBlogKey, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String = "home_$accountKey" - override suspend fun initialize(): InitializeAction = InitializeAction.SKIP_INITIAL_REFRESH - - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { @@ -52,11 +46,7 @@ internal class HomeTimelineRemoteMediator( return PagingResult( endOfPaginationReached = response.isEmpty(), - data = - response.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ), + data = response.render(accountKey), nextKey = response.next, previousKey = response.prev, ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/ListTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/ListTimelineRemoteMediator.kt index 03f6c3bcf..10b8117ad 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/ListTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/ListTimelineRemoteMediator.kt @@ -1,30 +1,26 @@ package dev.dimension.flare.data.datasource.mastodon import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.mastodon.MastodonService import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class ListTimelineRemoteMediator( private val listId: String, private val service: MastodonService, - database: CacheDatabase, private val accountKey: MicroBlogKey, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey = "list_${accountKey}_$listId" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { @@ -52,11 +48,7 @@ internal class ListTimelineRemoteMediator( return PagingResult( endOfPaginationReached = response.isEmpty(), - data = - response.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ), + data = response.render(accountKey), nextKey = response.next, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt index 37d108f14..ae5da7d58 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt @@ -1,38 +1,33 @@ package dev.dimension.flare.data.datasource.mastodon import androidx.paging.ExperimentalPagingApi -import androidx.paging.Pager -import androidx.paging.PagingData -import androidx.paging.cachedIn -import dev.dimension.flare.common.CacheData -import dev.dimension.flare.common.Cacheable import dev.dimension.flare.common.FileType -import dev.dimension.flare.common.MemCacheable -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.connect -import dev.dimension.flare.data.database.cache.mapper.Mastodon -import dev.dimension.flare.data.database.cache.mapper.toDb -import dev.dimension.flare.data.database.cache.mapper.toDbUser -import dev.dimension.flare.data.database.cache.model.StatusContent -import dev.dimension.flare.data.database.cache.model.updateStatusUseCase import dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataSource import dev.dimension.flare.data.datasource.microblog.ComposeConfig import dev.dimension.flare.data.datasource.microblog.ComposeData import dev.dimension.flare.data.datasource.microblog.ComposeProgress import dev.dimension.flare.data.datasource.microblog.ComposeType +import dev.dimension.flare.data.datasource.microblog.DatabaseUpdater import dev.dimension.flare.data.datasource.microblog.NotificationFilter -import dev.dimension.flare.data.datasource.microblog.ProfileAction +import dev.dimension.flare.data.datasource.microblog.PostEvent import dev.dimension.flare.data.datasource.microblog.ProfileTab -import dev.dimension.flare.data.datasource.microblog.RelationDataSource -import dev.dimension.flare.data.datasource.microblog.StatusEvent -import dev.dimension.flare.data.datasource.microblog.list.ListDataSource -import dev.dimension.flare.data.datasource.microblog.list.ListHandler -import dev.dimension.flare.data.datasource.microblog.list.ListLoader -import dev.dimension.flare.data.datasource.microblog.list.ListMemberHandler -import dev.dimension.flare.data.datasource.microblog.list.ListMemberLoader -import dev.dimension.flare.data.datasource.microblog.pagingConfig -import dev.dimension.flare.data.datasource.microblog.relationKeyWithUserKey -import dev.dimension.flare.data.datasource.microblog.timelinePager +import dev.dimension.flare.data.datasource.microblog.datasource.ListDataSource +import dev.dimension.flare.data.datasource.microblog.datasource.NotificationDataSource +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.UserDataSource +import dev.dimension.flare.data.datasource.microblog.handler.EmojiHandler +import dev.dimension.flare.data.datasource.microblog.handler.ListHandler +import dev.dimension.flare.data.datasource.microblog.handler.ListMemberHandler +import dev.dimension.flare.data.datasource.microblog.handler.NotificationHandler +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.ListLoader +import dev.dimension.flare.data.datasource.microblog.loader.ListMemberLoader +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader +import dev.dimension.flare.data.datasource.microblog.paging.notSupported import dev.dimension.flare.data.datasource.pleroma.PleromaDataSource import dev.dimension.flare.data.network.mastodon.MastodonService import dev.dimension.flare.data.network.mastodon.api.model.PostPoll @@ -41,34 +36,18 @@ import dev.dimension.flare.data.network.mastodon.api.model.PostStatus import dev.dimension.flare.data.network.mastodon.api.model.PostVote import dev.dimension.flare.data.network.mastodon.api.model.Visibility import dev.dimension.flare.data.repository.AccountRepository -import dev.dimension.flare.data.repository.LocalFilterRepository import dev.dimension.flare.data.repository.tryRun import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.model.PlatformType import dev.dimension.flare.shared.image.ImageCompressor import dev.dimension.flare.ui.model.UiAccount -import dev.dimension.flare.ui.model.UiEmoji import dev.dimension.flare.ui.model.UiHashtag import dev.dimension.flare.ui.model.UiProfile -import dev.dimension.flare.ui.model.UiRelation -import dev.dimension.flare.ui.model.UiState -import dev.dimension.flare.ui.model.UiTimeline -import dev.dimension.flare.ui.model.mapper.render -import dev.dimension.flare.ui.model.mapper.toUi -import dev.dimension.flare.ui.model.toUi +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.presenter.compose.ComposeStatus import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.ImmutableMap -import kotlinx.collections.immutable.toImmutableList -import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject import kotlin.uuid.Uuid @@ -78,13 +57,13 @@ internal open class MastodonDataSource( override val accountKey: MicroBlogKey, val instance: String, ) : AuthenticatedMicroblogDataSource, + NotificationDataSource, + UserDataSource, + PostDataSource, KoinComponent, - StatusEvent.Mastodon, ListDataSource, - RelationDataSource { - private val database: CacheDatabase by inject() - private val localFilterRepository: LocalFilterRepository by inject() - private val coroutineScope: CoroutineScope by inject() + RelationDataSource, + PostEventHandler.Handler { private val accountRepository: AccountRepository by inject() private val imageCompressor: ImageCompressor by inject() private val service by lazy { @@ -97,63 +76,94 @@ internal open class MastodonDataSource( ) } - override fun homeTimeline() = - HomeTimelineRemoteMediator( - service, - database, - accountKey, + val loader by lazy { + MastodonLoader( + accountKey = accountKey, + service = service, ) + } - fun localTimeline( - pageSize: Int = 20, - scope: CoroutineScope, - ): Flow> = - timelinePager( - pageSize = pageSize, - database = database, - scope = scope, - filterFlow = localFilterRepository.getFlow(forTimeline = true), - accountRepository = accountRepository, - mediator = publicTimelineLoader(local = true), + val emojiHandler by lazy { + EmojiHandler( + host = accountKey.host, + loader = loader, ) + } - fun bookmarkTimeline( - pageSize: Int = 20, - scope: CoroutineScope, - ): Flow> = - timelinePager( - pageSize = pageSize, - database = database, - scope = scope, - filterFlow = localFilterRepository.getFlow(forTimeline = true), - accountRepository = accountRepository, - mediator = bookmarkTimelineLoader(), + override val notificationHandler by lazy { + NotificationHandler( + accountKey = accountKey, + loader = loader, ) + } - fun bookmarkTimelineLoader() = - BookmarkTimelineRemoteMediator( + 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 relationHandler by lazy { + RelationHandler( + accountType = AccountType.Specific(accountKey), + dataSource = loader, + ) + } + + override val supportedRelationTypes: Set + get() = loader.supportedTypes + + override val postEventHandler by lazy { + PostEventHandler( + accountType = AccountType.Specific(accountKey), + handler = this, + ) + } + + override suspend fun handle( + event: PostEvent, + updater: DatabaseUpdater, + ) { + require(event is PostEvent.Mastodon) + when (event) { + is PostEvent.Mastodon.AcceptFollowRequest -> + acceptFollowRequest(event, updater) + is PostEvent.Mastodon.Bookmark -> + bookmark(event, updater) + is PostEvent.Mastodon.Like -> + like(event, updater) + is PostEvent.Mastodon.Reblog -> + reblog(event, updater) + is PostEvent.Mastodon.RejectFollowRequest -> + rejectFollowRequest(event, updater) + is PostEvent.Mastodon.Vote -> + vote(event, updater) + } + } + + override fun homeTimeline() = + HomeTimelineRemoteMediator( service, - database, accountKey, ) - fun favouriteTimeline( - pageSize: Int = 20, - scope: CoroutineScope, - ): Flow> = - timelinePager( - pageSize = pageSize, - database = database, - scope = scope, - filterFlow = localFilterRepository.getFlow(forTimeline = true), - accountRepository = accountRepository, - mediator = favouriteTimelineLoader(), + fun bookmarkTimelineLoader() = + BookmarkTimelineRemoteMediator( + service, + accountKey, ) fun favouriteTimelineLoader() = FavouriteTimelineRemoteMediator( service, - database, accountKey, ) @@ -161,64 +171,35 @@ internal open class MastodonDataSource( ListTimelineRemoteMediator( listId, service, - database, accountKey, ) - fun publicTimeline( - pageSize: Int = 20, - scope: CoroutineScope, - ): Flow> = - timelinePager( - pageSize = pageSize, - database = database, - scope = scope, - filterFlow = localFilterRepository.getFlow(forTimeline = true), - accountRepository = accountRepository, - mediator = publicTimelineLoader(local = false), - ) - fun publicTimelineLoader(local: Boolean) = PublicTimelineRemoteMediator( service, - database, accountKey, local = local, ) - override fun notification( - type: NotificationFilter, - pageSize: Int, - scope: CoroutineScope, - ): Flow> = - timelinePager( - pageSize = pageSize, - database = database, - scope = scope, - filterFlow = localFilterRepository.getFlow(forNotification = true), - accountRepository = accountRepository, - mediator = - when (type) { - NotificationFilter.All -> - NotificationRemoteMediator( - service, - database, - accountKey, - onClearMarker = { - MemCacheable.update(notificationMarkerKey, 0) - }, - ) + override fun notification(type: NotificationFilter): RemoteLoader = + when (type) { + NotificationFilter.All -> + NotificationRemoteMediator( + service, + accountKey, + onClearMarker = { + notificationHandler.clear() + }, + ) - NotificationFilter.Mention -> - MentionRemoteMediator( - service, - database, - accountKey, - ) + NotificationFilter.Mention -> + MentionRemoteMediator( + service, + accountKey, + ) - else -> throw IllegalStateException("Unsupported notification type") - }, - ) + else -> notSupported() + } override val supportedNotificationFilter: List get() = @@ -227,56 +208,11 @@ internal open class MastodonDataSource( NotificationFilter.Mention, ) - override fun userByAcct(acct: String): CacheData { - val (name, host) = MicroBlogKey.valueOf(acct) - return Cacheable( - fetchSource = { - val user = - service - .lookupUserByAcct("$name@$host") - ?.toDbUser(accountKey.host) ?: throw Exception("User not found") - database.userDao().insert(user) - }, - cacheSource = { - database - .userDao() - .findByHandleAndHost(name, host, PlatformType.Mastodon) - .distinctUntilChanged() - .mapNotNull { it?.render(accountKey) } - }, - ) - } - - override fun userById(id: String): CacheData { - val userKey = MicroBlogKey(id, accountKey.host) - return Cacheable( - fetchSource = { - val user = service.lookupUser(id).toDbUser(accountKey.host) - database.userDao().insert(user) - }, - cacheSource = { - database - .userDao() - .findByKey(userKey) - .distinctUntilChanged() - .mapNotNull { it?.render(accountKey) } - }, - ) - } - - override fun relation(userKey: MicroBlogKey): Flow> = - MemCacheable( - relationKeyWithUserKey(userKey), - ) { - service.showFriendships(listOf(userKey.id)).first().toUi() - }.toUi() - override fun userTimeline( userKey: MicroBlogKey, mediaOnly: Boolean, ) = UserTimelineRemoteMediator( service, - database, accountKey, userKey, onlyMedia = mediaOnly, @@ -286,58 +222,10 @@ internal open class MastodonDataSource( StatusDetailRemoteMediator( statusKey, service, - database, accountKey, statusOnly = false, ) - override fun status(statusKey: MicroBlogKey): CacheData { - val pagingKey = "status_only_$statusKey" - return Cacheable( - fetchSource = { - val result = - service.lookupStatus( - statusKey.id, - ) - Mastodon.save( - database = database, - accountKey = accountKey, - pagingKey = pagingKey, - data = listOf(result), - ) - }, - cacheSource = { - database - .statusDao() - .get(statusKey, AccountType.Specific(accountKey)) - .distinctUntilChanged() - .mapNotNull { it?.content?.render(this) } - }, - ) - } - - fun emoji(): Cacheable>> = - Cacheable( - fetchSource = { - val emojis = service.emojis() - database.emojiDao().insert(emojis.toDb(accountKey.host)) - }, - cacheSource = { - database - .emojiDao() - .get(accountKey.host) - .distinctUntilChanged() - .mapNotNull { - it - ?.toUi() - ?.groupBy { it.category } - ?.map { it.key to it.value.toImmutableList() } - ?.toMap() - ?.toImmutableMap() - } - }, - ) - override suspend fun compose( data: ComposeData, progress: (ComposeProgress) -> Unit, @@ -390,11 +278,11 @@ internal open class MastodonDataSource( status = data.content, visibility = when (data.visibility) { - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Public -> Visibility.Public - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Home -> Visibility.Unlisted - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Followers -> Visibility.Private - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Specified -> Visibility.Direct - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Channel -> Visibility.Public + UiTimelineV2.Post.Visibility.Public -> Visibility.Public + UiTimelineV2.Post.Visibility.Home -> Visibility.Unlisted + UiTimelineV2.Post.Visibility.Followers -> Visibility.Private + UiTimelineV2.Post.Visibility.Specified -> Visibility.Direct + UiTimelineV2.Post.Visibility.Channel -> Visibility.Public }, inReplyToID = inReplyToID, mediaIDS = mediaIds.takeIf { it.isNotEmpty() }, @@ -426,201 +314,42 @@ internal open class MastodonDataSource( // progress(ComposeProgress(maxProgress, maxProgress)) } - override fun like( - statusKey: MicroBlogKey, - liked: Boolean, + suspend fun like( + event: PostEvent.Mastodon.Like, + updater: DatabaseUpdater, ) { - coroutineScope.launch { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - favourited = !liked, - favouritesCount = - if (liked) { - it.data.favouritesCount?.minus(1) - } else { - it.data.favouritesCount?.plus(1) - }, - ), - ) - }, - ) - - tryRun { - if (liked) { - service.unfavourite(statusKey.id) - } else { - service.favourite(statusKey.id) - } - }.onFailure { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - favourited = liked, - favouritesCount = - if (!liked) { - it.data.favouritesCount?.minus(1) - } else { - it.data.favouritesCount?.plus(1) - }, - ), - ) - }, - ) - }.onSuccess { result -> - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy(data = result) - }, - ) - } + val statusKey = event.postKey + val liked = event.liked + if (liked) { + service.unfavourite(statusKey.id) + } else { + service.favourite(statusKey.id) } } - override fun reblog( - statusKey: MicroBlogKey, - reblogged: Boolean, + suspend fun reblog( + event: PostEvent.Mastodon.Reblog, + updater: DatabaseUpdater, ) { - coroutineScope.launch { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - reblogged = !reblogged, - reblogsCount = - if (reblogged) { - it.data.reblogsCount?.minus(1) - } else { - it.data.reblogsCount?.plus(1) - }, - ), - ) - }, - ) - - tryRun { - if (reblogged) { - service.unreblog(statusKey.id) - } else { - service.reblog(statusKey.id) - } - }.onFailure { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - reblogged = reblogged, - reblogsCount = - if (!reblogged) { - it.data.reblogsCount?.minus(1) - } else { - it.data.reblogsCount?.plus(1) - }, - ), - ) - }, - ) - }.onSuccess { result -> - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - result.reblog?.let { StatusContent.Mastodon(it) } ?: it - }, - ) - } + val statusKey = event.postKey + val reblogged = event.reblogged + if (reblogged) { + service.unreblog(statusKey.id) + } else { + service.reblog(statusKey.id) } } - override suspend fun deleteStatus(statusKey: MicroBlogKey) { - tryRun { - service.delete(statusKey.id) - // delete status from cache - database.connect { - database.statusDao().delete( - statusKey = statusKey, - accountType = AccountType.Specific(accountKey), - ) - database.statusReferenceDao().delete(statusKey) - database.pagingTimelineDao().deleteStatus( - accountKey = accountKey, - statusKey = statusKey, - ) - } - } - } - - override fun bookmark( - statusKey: MicroBlogKey, - bookmarked: Boolean, + suspend fun bookmark( + event: PostEvent.Mastodon.Bookmark, + updater: DatabaseUpdater, ) { - coroutineScope.launch { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - bookmarked = !bookmarked, - ), - ) - }, - ) - - tryRun { - if (bookmarked) { - service.unbookmark(statusKey.id) - } else { - service.bookmark(statusKey.id) - } - }.onFailure { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - bookmarked = bookmarked, - ), - ) - }, - ) - }.onSuccess { result -> - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy(data = result) - }, - ) - } + val statusKey = event.postKey + val bookmarked = event.bookmarked + if (bookmarked) { + service.unbookmark(statusKey.id) + } else { + service.bookmark(statusKey.id) } } @@ -638,205 +367,38 @@ internal open class MastodonDataSource( } } - suspend fun unfollow(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - following = false, - ) - } - tryRun { - service.unfollow(userKey.id) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - following = true, - ) - } - } - } - - suspend fun follow(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - following = true, - ) - } - tryRun { - service.follow(userKey.id) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - following = false, - ) - } - } - } - - override suspend fun block(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - blocking = true, - ) - } - tryRun { - service.block(userKey.id) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - blocking = false, - ) - } - } - } - - suspend fun unblock(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - blocking = false, - ) - } - tryRun { - service.unblock(userKey.id) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - blocking = true, - ) - } - } - } - - override suspend fun mute(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - muted = true, - ) - } - tryRun { - service.muteUser(userKey.id) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - muted = false, - ) - } - } - } - - suspend fun unmute(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - muted = false, - ) - } - tryRun { - service.unmuteUser(userKey.id) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - muted = true, - ) - } - } - } - - override fun discoverUsers(pageSize: Int): Flow> = - Pager( - config = pagingConfig, - ) { - TrendsUserPagingSource( - service = service, - accountKey = accountKey, - host = accountKey.host, - ) - }.flow + override fun discoverUsers(): RemoteLoader = + TrendsUserLoader( + service = service, + accountKey = accountKey, + host = accountKey.host, + ) override fun discoverStatuses() = DiscoverStatusRemoteMediator( service, - database, accountKey, ) - override fun discoverHashtags(pageSize: Int): Flow> = - Pager( - config = pagingConfig, - ) { - TrendHashtagPagingSource( - service, - ) - }.flow + override fun discoverHashtags(): RemoteLoader = + TrendHashtagPagingSource( + service = service, + ) override fun searchStatus(query: String) = SearchStatusPagingSource( service, - database, accountKey, query, ) - override fun searchUser( - query: String, - pageSize: Int, - ): Flow> = - Pager( - config = pagingConfig, - ) { - SearchUserPagingSource( - service = service, - accountKey = accountKey, - query = query, - host = accountKey.host, - ) - }.flow - - fun searchFollowing( - query: String, - scope: CoroutineScope, - pageSize: Int = 20, - ): Flow> = - Pager( - config = pagingConfig, - ) { - SearchUserPagingSource( - service = service, - accountKey = accountKey, - query = query, - host = accountKey.host, - following = true, - resolve = false, - ) - }.flow.cachedIn(scope) + override fun searchUser(query: String): RemoteLoader = + SearchUserPagingSource( + service = service, + accountKey = accountKey, + query = query, + host = accountKey.host, + ) override fun composeConfig(type: ComposeType): ComposeConfig = ComposeConfig( @@ -858,56 +420,12 @@ internal open class MastodonDataSource( } else { ComposeConfig.Poll(4) }, - emoji = ComposeConfig.Emoji(emoji(), mergeTag = "mastodon@${accountKey.host}"), + emoji = ComposeConfig.Emoji(emojiHandler.emoji, mergeTag = "mastodon@${accountKey.host}"), contentWarning = ComposeConfig.ContentWarning, visibility = ComposeConfig.Visibility, language = ComposeConfig.Language(1), ) - override suspend fun follow( - userKey: MicroBlogKey, - relation: UiRelation, - ) { - when { - relation.following -> unfollow(userKey) - relation.blocking -> unblock(userKey) - relation.hasPendingFollowRequestFromYou -> Unit // you can't cancel follow request on mastodon - else -> follow(userKey) - } - } - - override fun profileActions(): List = - listOf( - object : ProfileAction.Mute { - override suspend fun invoke( - userKey: MicroBlogKey, - relation: UiRelation, - ) { - if (relation.muted) { - unmute(userKey) - } else { - mute(userKey) - } - } - - override fun relationState(relation: UiRelation): Boolean = relation.muted - }, - object : ProfileAction.Block { - override suspend fun invoke( - userKey: MicroBlogKey, - relation: UiRelation, - ) { - if (relation.blocking) { - unblock(userKey) - } else { - block(userKey) - } - } - - override fun relationState(relation: UiRelation): Boolean = relation.blocking - }, - ) - val listLoader: ListLoader by lazy { MastodonListLoader( service = service, @@ -938,140 +456,29 @@ internal open class MastodonDataSource( ) } - private val notificationMarkerKey: String - get() = "notificationBadgeCount_$accountKey" - - override fun notificationBadgeCount(): CacheData { - return MemCacheable( - key = notificationMarkerKey, - fetchSource = { - val marker = - service.notificationMarkers().notifications?.lastReadID ?: return@MemCacheable 0 - val timeline = service.notification(min_id = marker) - timeline.size - }, - ) - } - - override fun vote( - statusKey: MicroBlogKey, - id: String, - options: List, + suspend fun vote( + event: PostEvent.Mastodon.Vote, + updater: DatabaseUpdater, ) { - coroutineScope.launch { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - poll = - it.data.poll?.copy( - voted = true, - ownVotes = options, - options = - it.data.poll.options?.mapIndexed { index, option -> - if (options.contains(index)) { - option.copy( - votesCount = - option.votesCount?.plus( - 1, - ), - ) - } else { - option - } - } ?: emptyList(), - ), - ), - ) - }, - ) - - tryRun { - service.vote(id = id, data = PostVote(choices = options.map { it.toString() })) - }.onFailure { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - poll = - it.data.poll?.copy( - voted = false, - ownVotes = null, - options = - it.data.poll.options?.mapIndexed { index, option -> - if (options.contains(index)) { - option.copy( - votesCount = - option.votesCount?.minus( - 1, - ), - ) - } else { - option - } - } ?: emptyList(), - ), - ), - ) - }, - ) - }.onSuccess { result -> - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - poll = result, - ), - ) - }, - ) - } - } + val options = event.options + service.vote(id = event.id, data = PostVote(choices = options.map { it.toString() })) } - override fun following( - userKey: MicroBlogKey, - scope: CoroutineScope, - pageSize: Int, - ): Flow> = - Pager( - config = pagingConfig, - ) { - MastodonFollowingPagingSource( - service = service, - host = accountKey.host, - userKey = userKey, - accountKey = accountKey, - ) - }.flow.cachedIn(scope) + override fun fans(userKey: MicroBlogKey): RemoteLoader = + MastodonFansPagingSource( + service = service, + host = accountKey.host, + userKey = userKey, + accountKey = accountKey, + ) - override fun fans( - userKey: MicroBlogKey, - scope: CoroutineScope, - pageSize: Int, - ): Flow> = - Pager( - config = pagingConfig, - ) { - MastodonFansPagingSource( - service = service, - host = accountKey.host, - userKey = userKey, - accountKey = accountKey, - ) - }.flow.cachedIn(scope) + override fun following(userKey: MicroBlogKey): RemoteLoader = + MastodonFollowingPagingSource( + service = service, + host = accountKey.host, + userKey = userKey, + accountKey = accountKey, + ) override fun profileTabs(userKey: MicroBlogKey): ImmutableList = listOfNotNull( @@ -1080,7 +487,6 @@ internal open class MastodonDataSource( loader = UserTimelineRemoteMediator( service = service, - database = database, accountKey = accountKey, userKey = userKey, withPinned = true, @@ -1092,7 +498,6 @@ internal open class MastodonDataSource( UserTimelineRemoteMediator( service = service, accountKey = accountKey, - database = database, userKey = userKey, withReplies = true, ), @@ -1100,73 +505,25 @@ internal open class MastodonDataSource( ProfileTab.Media, ).toPersistentList() - override fun acceptFollowRequest( - userKey: MicroBlogKey, - notificationStatusKey: MicroBlogKey, + suspend fun acceptFollowRequest( + event: PostEvent.Mastodon.AcceptFollowRequest, + updater: DatabaseUpdater, ) { - coroutineScope.launch { - tryRun { - MemCacheable.updateWith( - key = relationKeyWithUserKey(userKey), - ) { - it.copy( - hasPendingFollowRequestToYou = false, - isFans = true, - ) - } - service.authorizeFollowRequest( - id = userKey.id, - ) - }.onFailure { - MemCacheable.updateWith( - key = relationKeyWithUserKey(userKey), - ) { - it.copy( - hasPendingFollowRequestToYou = true, - isFans = false, - ) - } - }.onSuccess { - database.pagingTimelineDao().deleteStatus( - accountKey = accountKey, - statusKey = notificationStatusKey, - ) - } - } + service.authorizeFollowRequest( + id = event.userKey.id, + ) + updater.deleteFromCache(event.postKey) + relationHandler.approveFollowRequest(event.userKey) } - override fun rejectFollowRequest( - userKey: MicroBlogKey, - notificationStatusKey: MicroBlogKey, + suspend fun rejectFollowRequest( + event: PostEvent.Mastodon.RejectFollowRequest, + updater: DatabaseUpdater, ) { - coroutineScope.launch { - tryRun { - MemCacheable.updateWith( - key = relationKeyWithUserKey(userKey), - ) { - it.copy( - hasPendingFollowRequestToYou = false, - isFans = false, - ) - } - service.rejectFollowRequest( - id = userKey.id, - ) - }.onFailure { - MemCacheable.updateWith( - key = relationKeyWithUserKey(userKey), - ) { - it.copy( - hasPendingFollowRequestToYou = true, - isFans = false, - ) - } - }.onSuccess { - database.pagingTimelineDao().deleteStatus( - accountKey = accountKey, - statusKey = notificationStatusKey, - ) - } - } + service.rejectFollowRequest( + id = event.userKey.id, + ) + updater.deleteFromCache(event.postKey) + relationHandler.rejectFollowRequest(event.userKey) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonFansPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonFansPagingSource.kt index 063b486fa..b392fc9da 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonFansPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonFansPagingSource.kt @@ -1,7 +1,8 @@ package dev.dimension.flare.data.datasource.mastodon -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.mastodon.api.AccountResources import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiProfile @@ -12,25 +13,39 @@ internal class MastodonFansPagingSource( private val accountKey: MicroBlogKey?, private val host: String, private val userKey: MicroBlogKey, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null - - override suspend fun doLoad(params: LoadParams): LoadResult { - val maxId = params.key - val limit = params.loadSize +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { val response = - service - .followers( - id = userKey.id, - limit = limit, - max_id = maxId, - ) - return LoadResult.Page( - data = - response.map { - it.render(accountKey = accountKey, host = host) - }, - prevKey = null, + when (request) { + PagingRequest.Refresh -> { + service + .followers( + id = userKey.id, + limit = pageSize, + ) + } + + is PagingRequest.Prepend -> { + return PagingResult( + endOfPaginationReached = true, + ) + } + + is PagingRequest.Append -> { + service.followers( + id = userKey.id, + limit = pageSize, + max_id = request.nextKey, + ) + } + } + + return PagingResult( + endOfPaginationReached = response.isEmpty() || response.next == null, + data = response.map { it.render(accountKey = accountKey, host = host) }, nextKey = response.next, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonFollowingPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonFollowingPagingSource.kt index 8931fae45..aa30046b9 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonFollowingPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonFollowingPagingSource.kt @@ -1,7 +1,8 @@ package dev.dimension.flare.data.datasource.mastodon -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.mastodon.api.AccountResources import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiProfile @@ -12,25 +13,39 @@ internal class MastodonFollowingPagingSource( private val accountKey: MicroBlogKey?, private val host: String, private val userKey: MicroBlogKey, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null - - override suspend fun doLoad(params: LoadParams): LoadResult { - val maxId = params.key - val limit = params.loadSize +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { val response = - service - .following( - id = userKey.id, - limit = limit, - max_id = maxId, - ) - return LoadResult.Page( - data = - response.map { - it.render(accountKey = accountKey, host = host) - }, - prevKey = null, + when (request) { + PagingRequest.Refresh -> { + service + .following( + id = userKey.id, + limit = pageSize, + ) + } + + is PagingRequest.Prepend -> { + return PagingResult( + endOfPaginationReached = true, + ) + } + + is PagingRequest.Append -> { + service.following( + id = userKey.id, + limit = pageSize, + max_id = request.nextKey, + ) + } + } + + return PagingResult( + endOfPaginationReached = response.isEmpty() || response.next == null, + data = response.map { it.render(accountKey = accountKey, host = host) }, nextKey = response.next, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonListLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonListLoader.kt index 8b4f180ea..c41cea3c5 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonListLoader.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonListLoader.kt @@ -1,8 +1,8 @@ package dev.dimension.flare.data.datasource.mastodon -import dev.dimension.flare.data.datasource.microblog.list.ListLoader import dev.dimension.flare.data.datasource.microblog.list.ListMetaData import dev.dimension.flare.data.datasource.microblog.list.ListMetaDataType +import dev.dimension.flare.data.datasource.microblog.loader.ListLoader import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.mastodon.MastodonService diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonListMemberLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonListMemberLoader.kt index 5dda7a782..a4c2bf80f 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonListMemberLoader.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonListMemberLoader.kt @@ -1,8 +1,6 @@ package dev.dimension.flare.data.datasource.mastodon -import dev.dimension.flare.data.database.cache.mapper.toDbUser -import dev.dimension.flare.data.database.cache.model.DbUser -import dev.dimension.flare.data.datasource.microblog.list.ListMemberLoader +import dev.dimension.flare.data.datasource.microblog.loader.ListMemberLoader import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.mastodon.MastodonService @@ -10,6 +8,8 @@ import dev.dimension.flare.data.network.mastodon.api.model.MastodonList import dev.dimension.flare.data.network.mastodon.api.model.PostAccounts import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiList +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.mapper.render internal class MastodonListMemberLoader( private val service: MastodonService, @@ -19,7 +19,7 @@ internal class MastodonListMemberLoader( pageSize: Int, request: PagingRequest, listId: String, - ): PagingResult { + ): PagingResult { val maxId = when (request) { is PagingRequest.Append -> request.nextKey @@ -36,7 +36,7 @@ internal class MastodonListMemberLoader( val users = response.map { - it.toDbUser(accountKey.host) + it.render(accountKey = accountKey, host = accountKey.host) } return PagingResult( @@ -48,14 +48,14 @@ internal class MastodonListMemberLoader( override suspend fun addMember( listId: String, userKey: MicroBlogKey, - ): DbUser { + ): UiProfile { service.addMember( listId = listId, accounts = PostAccounts(listOf(userKey.id)), ) return service .lookupUser(userKey.id) - .toDbUser(accountKey.host) + .render(accountKey = accountKey, host = accountKey.host) } override suspend fun removeMember( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonLoader.kt new file mode 100644 index 000000000..7a2f8fcfb --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonLoader.kt @@ -0,0 +1,130 @@ +package dev.dimension.flare.data.datasource.mastodon + +import dev.dimension.flare.data.datasource.microblog.loader.EmojiLoader +import dev.dimension.flare.data.datasource.microblog.loader.NotificationLoader +import dev.dimension.flare.data.datasource.microblog.loader.PostLoader +import dev.dimension.flare.data.datasource.microblog.loader.RelationActionType +import dev.dimension.flare.data.datasource.microblog.loader.RelationLoader +import dev.dimension.flare.data.datasource.microblog.loader.UserLoader +import dev.dimension.flare.data.network.mastodon.MastodonService +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiEmoji +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 dev.dimension.flare.ui.model.mapper.render +import dev.dimension.flare.ui.model.mapper.toUi +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap + +internal class MastodonLoader( + val accountKey: MicroBlogKey, + private val service: MastodonService, +) : NotificationLoader, + UserLoader, + PostLoader, + RelationLoader, + EmojiLoader { + override val supportedTypes: Set = + setOf( + RelationActionType.Follow, + RelationActionType.Block, + RelationActionType.Mute, + ) + + override suspend fun userByHandleAndHost(uiHandle: UiHandle): UiProfile = + service + .lookupUserByAcct("${uiHandle.normalizedRaw}@${uiHandle.normalizedHost}") + ?.render( + accountKey = accountKey, + host = accountKey.host, + ) ?: throw Exception("User not found") + + override suspend fun userById(id: String): UiProfile = + service + .lookupUser(id) + .render( + accountKey = accountKey, + host = accountKey.host, + ) + + override suspend fun relation(userKey: MicroBlogKey): UiRelation = service.showFriendships(listOf(userKey.id)).first().toUi() + + override suspend fun unfollow(userKey: MicroBlogKey) { + service.unfollow(userKey.id) + } + + override suspend fun follow(userKey: MicroBlogKey) { + service.follow(userKey.id) + } + + override suspend fun block(userKey: MicroBlogKey) { + service.block(userKey.id) + } + + override suspend fun unblock(userKey: MicroBlogKey) { + service.unblock(userKey.id) + } + + override suspend fun mute(userKey: MicroBlogKey) { + service.muteUser(userKey.id) + } + + override suspend fun unmute(userKey: MicroBlogKey) { + service.unmuteUser(userKey.id) + } + + override suspend fun status(statusKey: MicroBlogKey): UiTimelineV2 = + service + .lookupStatus( + statusKey.id, + ).render( + accountKey = accountKey, + host = accountKey.host, + ) + + override suspend fun deleteStatus(statusKey: MicroBlogKey) { + service.delete(statusKey.id) + } + + override suspend fun notificationBadgeCount(): Int { + val marker = + service.notificationMarkers().notifications?.lastReadID ?: return 0 + val timeline = service.notification(min_id = marker) + return timeline.size + } + + override suspend fun emojis(): ImmutableMap> { + return service + .emojis() + .filter { it.visibleInPicker == true } + .groupBy { it.category } + .mapNotNull { + val category = it.key ?: return@mapNotNull null + category to it.value + }.map { (category, value) -> + category to + value + .map { + val shortCode = + it.shortcode + .orEmpty() + .let { if (!it.startsWith(':') && !it.endsWith(':')) ":$it:" else it } + UiEmoji( + shortcode = shortCode, + url = it.url.orEmpty(), + category = it.category.orEmpty(), + searchKeywords = + listOfNotNull( + it.shortcode, + ).toImmutableList(), + insertText = " $shortCode ", + ) + }.toImmutableList() + }.toMap() + .toImmutableMap() + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MentionRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MentionRemoteMediator.kt index 1d1b32614..03fd15e28 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MentionRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MentionRemoteMediator.kt @@ -1,30 +1,26 @@ package dev.dimension.flare.data.datasource.mastodon import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDb -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.mastodon.MastodonService import dev.dimension.flare.data.network.mastodon.api.model.NotificationTypes import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class MentionRemoteMediator( private val service: MastodonService, - database: CacheDatabase, private val accountKey: MicroBlogKey, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String = "mention_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { @@ -54,11 +50,7 @@ internal class MentionRemoteMediator( return PagingResult( endOfPaginationReached = response.isEmpty(), - data = - response.toDb( - accountKey = accountKey, - pagingKey = pagingKey, - ), + data = response.map { it.render(accountKey) }, nextKey = response.next, previousKey = response.prev, ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/NotificationRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/NotificationRemoteMediator.kt index 71a596eec..abec22367 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/NotificationRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/NotificationRemoteMediator.kt @@ -1,32 +1,28 @@ package dev.dimension.flare.data.datasource.mastodon import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDb -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.mastodon.MastodonService import dev.dimension.flare.data.network.mastodon.api.model.MarkerUpdate import dev.dimension.flare.data.network.mastodon.api.model.UpdateContent import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class NotificationRemoteMediator( private val service: MastodonService, - database: CacheDatabase, private val accountKey: MicroBlogKey, private val onClearMarker: () -> Unit, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String = "notification_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { @@ -58,11 +54,7 @@ internal class NotificationRemoteMediator( return PagingResult( endOfPaginationReached = response.isEmpty(), - data = - response.toDb( - accountKey = accountKey, - pagingKey = pagingKey, - ), + data = response.map { it.render(accountKey) }, nextKey = response.next, previousKey = response.prev, ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/PublicTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/PublicTimelineRemoteMediator.kt index a2856540a..7512df474 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/PublicTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/PublicTimelineRemoteMediator.kt @@ -1,24 +1,20 @@ package dev.dimension.flare.data.datasource.mastodon import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.mastodon.MastodonService import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class PublicTimelineRemoteMediator( private val service: MastodonService, - database: CacheDatabase, private val accountKey: MicroBlogKey, private val local: Boolean, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String = buildString { append("public_timeline") @@ -28,10 +24,10 @@ internal class PublicTimelineRemoteMediator( append("_$accountKey") } - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { @@ -59,11 +55,7 @@ internal class PublicTimelineRemoteMediator( return PagingResult( endOfPaginationReached = response.isEmpty(), - data = - response.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ), + data = response.render(accountKey), nextKey = response.next, previousKey = response.prev, ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/SearchStatusPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/SearchStatusPagingSource.kt index 2ffdc03b5..fd5023ad6 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/SearchStatusPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/SearchStatusPagingSource.kt @@ -1,24 +1,20 @@ package dev.dimension.flare.data.datasource.mastodon import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.mastodon.MastodonService import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class SearchStatusPagingSource( private val service: MastodonService, - database: CacheDatabase, private val accountKey: MicroBlogKey, private val query: String, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String = buildString { append("search_") @@ -26,10 +22,10 @@ internal class SearchStatusPagingSource( append(accountKey.toString()) } - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { is PagingRequest.Prepend -> { @@ -75,11 +71,7 @@ internal class SearchStatusPagingSource( return PagingResult( endOfPaginationReached = response.isEmpty(), - data = - response.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ), + data = response.render(accountKey), nextKey = response.lastOrNull()?.id, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/SearchUserPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/SearchUserPagingSource.kt index 50082516b..2be760878 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/SearchUserPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/SearchUserPagingSource.kt @@ -1,7 +1,8 @@ package dev.dimension.flare.data.datasource.mastodon -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.mastodon.api.SearchResources import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiProfile @@ -14,27 +15,49 @@ internal class SearchUserPagingSource( private val query: String, private val following: Boolean = false, private val resolve: Boolean? = null, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + val response = + when (request) { + is PagingRequest.Prepend -> { + return PagingResult( + endOfPaginationReached = true, + ) + } - override suspend fun doLoad(params: LoadParams): LoadResult { - service - .searchV2( - query = query, - limit = params.loadSize, - max_id = params.key, - type = "accounts", - following = following, - resolve = resolve, - ).accounts - ?.let { accounts -> - return LoadResult.Page( - data = accounts.map { it.render(accountKey = accountKey, host = host) }, - prevKey = null, - nextKey = accounts.lastOrNull()?.id?.takeIf { it != params.key && accounts.size == params.loadSize }, - ) - } ?: run { - return LoadResult.Error(Exception("No data")) - } + PagingRequest.Refresh -> { + service + .searchV2( + query = query, + limit = pageSize, + type = "accounts", + following = following, + resolve = resolve, + ).accounts ?: emptyList() + } + + is PagingRequest.Append -> { + service + .searchV2( + query = query, + limit = pageSize, + max_id = request.nextKey, + type = "accounts", + following = following, + resolve = resolve, + ).accounts ?: emptyList() + } + } + + return PagingResult( + data = response.map { it.render(accountKey = accountKey, host = host) }, + nextKey = + response.lastOrNull()?.id?.takeIf { + (request !is PagingRequest.Append || it != request.nextKey) && response.size == pageSize + }, + ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/StatusDetailRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/StatusDetailRemoteMediator.kt index bb6b09f3c..4bd129d12 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/StatusDetailRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/StatusDetailRemoteMediator.kt @@ -1,29 +1,23 @@ package dev.dimension.flare.data.datasource.mastodon import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.connect -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.mastodon.MastodonService -import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey -import kotlinx.coroutines.flow.firstOrNull +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render +import org.koin.core.component.KoinComponent @OptIn(ExperimentalPagingApi::class) internal class StatusDetailRemoteMediator( private val statusKey: MicroBlogKey, private val service: MastodonService, - private val database: CacheDatabase, private val accountKey: MicroBlogKey, private val statusOnly: Boolean, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader, + KoinComponent { override val pagingKey: String = buildString { append("status_detail_") @@ -35,10 +29,10 @@ internal class StatusDetailRemoteMediator( append(accountKey.toString()) } - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val result = when (request) { is PagingRequest.Append -> { @@ -63,26 +57,6 @@ internal class StatusDetailRemoteMediator( endOfPaginationReached = true, ) PagingRequest.Refresh -> { - val exists = database.pagingTimelineDao().existsPaging(accountKey, pagingKey) - if (!exists) { - val status = database.statusDao().get(statusKey, AccountType.Specific(accountKey)).firstOrNull() - status?.let { - database.connect { - database - .pagingTimelineDao() - .insertAll( - listOf( - DbPagingTimeline( - accountType = AccountType.Specific(accountKey), - statusKey = statusKey, - pagingKey = pagingKey, - sortId = 0, - ), - ), - ) - } - } - } val current = service.lookupStatus( statusKey.id, @@ -94,13 +68,7 @@ internal class StatusDetailRemoteMediator( return PagingResult( endOfPaginationReached = !shouldLoadMore, - data = - result.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ) { - -result.indexOf(it).toLong() - }, + data = result.render(accountKey), nextKey = if (shouldLoadMore) pagingKey else null, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/TrendHashtagPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/TrendHashtagPagingSource.kt index 49d84c185..72c18f96f 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/TrendHashtagPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/TrendHashtagPagingSource.kt @@ -1,30 +1,41 @@ package dev.dimension.flare.data.datasource.mastodon -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.mastodon.api.TrendsResources import dev.dimension.flare.ui.model.UiHashtag internal class TrendHashtagPagingSource( private val service: TrendsResources, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): Int? = null +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + val response = + when (request) { + is PagingRequest.Prepend, is PagingRequest.Append -> { + return PagingResult( + endOfPaginationReached = true, + ) + } - override suspend fun doLoad(params: LoadParams): LoadResult { - service - .trendsTags() - .map { - UiHashtag( - hashtag = it.name ?: "", - description = null, - searchContent = "#${it.name}", - ) - }.let { - return LoadResult.Page( - data = it, - prevKey = null, - nextKey = null, - ) + PagingRequest.Refresh -> { + service + .trendsTags() + .map { + UiHashtag( + hashtag = it.name ?: "", + description = null, + searchContent = "#${it.name}", + ) + } + } } + + return PagingResult( + data = response, + ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/TrendsUserLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/TrendsUserLoader.kt new file mode 100644 index 000000000..ffdcb6d5d --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/TrendsUserLoader.kt @@ -0,0 +1,39 @@ +package dev.dimension.flare.data.datasource.mastodon + +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader +import dev.dimension.flare.data.network.mastodon.api.TrendsResources +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.mapper.render + +internal class TrendsUserLoader( + private val service: TrendsResources, + private val accountKey: MicroBlogKey?, + private val host: String, +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request !is PagingRequest.Refresh) { + return PagingResult( + data = emptyList(), + nextKey = null, + previousKey = null, + ) + } + val data = + service + .suggestionsUsers() + .mapNotNull { + it.account?.render(accountKey = accountKey, host = host) + } + return PagingResult( + data = data, + nextKey = null, + previousKey = null, + ) + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/TrendsUserPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/TrendsUserPagingSource.kt deleted file mode 100644 index 1715e1dba..000000000 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/TrendsUserPagingSource.kt +++ /dev/null @@ -1,30 +0,0 @@ -package dev.dimension.flare.data.datasource.mastodon - -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource -import dev.dimension.flare.data.network.mastodon.api.TrendsResources -import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiProfile -import dev.dimension.flare.ui.model.mapper.render - -internal class TrendsUserPagingSource( - private val service: TrendsResources, - private val accountKey: MicroBlogKey?, - private val host: String, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): Int? = null - - override suspend fun doLoad(params: LoadParams): LoadResult { - service - .suggestionsUsers() - .mapNotNull { - it.account?.render(accountKey = accountKey, host = host) - }.let { - return LoadResult.Page( - data = it, - prevKey = null, - nextKey = null, - ) - } - } -} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/UserTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/UserTimelineRemoteMediator.kt index 8e38aa7aa..043112bda 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/UserTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/UserTimelineRemoteMediator.kt @@ -1,27 +1,23 @@ package dev.dimension.flare.data.datasource.mastodon import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.mastodon.MastodonService import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class UserTimelineRemoteMediator( private val service: MastodonService, - database: CacheDatabase, private val accountKey: MicroBlogKey, private val userKey: MicroBlogKey, private val onlyMedia: Boolean = false, private val withReplies: Boolean = false, private val withPinned: Boolean = false, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey = buildString { append("user_timeline") @@ -38,10 +34,10 @@ internal class UserTimelineRemoteMediator( append(userKey.toString()) } - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { @@ -83,18 +79,7 @@ internal class UserTimelineRemoteMediator( return PagingResult( endOfPaginationReached = response.isEmpty(), - data = - response.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - sortIdProvider = { - if (it.pinned == true) { - Long.MAX_VALUE - } else { - it.createdAt?.toEpochMilliseconds() ?: 0 - } - }, - ), + data = response.render(accountKey), nextKey = response.next, previousKey = response.prev, ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ActionMenu.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ActionMenu.kt index 3aade57fd..c11a5c7ec 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ActionMenu.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ActionMenu.kt @@ -1,88 +1,83 @@ package dev.dimension.flare.data.datasource.microblog import androidx.compose.runtime.Immutable +import dev.dimension.flare.common.SerializableImmutableList import dev.dimension.flare.model.MicroBlogKey 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.UiNumber -import dev.dimension.flare.ui.model.launch +import dev.dimension.flare.ui.model.onClicked import dev.dimension.flare.ui.route.DeeplinkRoute -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable @Immutable -public sealed interface ActionMenu { +public sealed class ActionMenu { @Immutable + @Serializable public data class Group internal constructor( val displayItem: Item, - val actions: ImmutableList, - ) : ActionMenu + val actions: SerializableImmutableList, + ) : ActionMenu() - @Immutable - public data class AsyncActionMenuItem internal constructor( - val flow: Flow, - ) : ActionMenu +// @Immutable +// @Serializable +// public data class AsyncActionMenuItem internal constructor( +// @Transient +// val flow: Flow = emptyFlow(), +// ) : ActionMenu() @Immutable - public data object Divider : ActionMenu + @Serializable + public data object Divider : ActionMenu() @Immutable + @Serializable public data class Item internal constructor( - val icon: Icon? = null, + internal val updateKey: String = "", + val icon: UiIcon? = null, val text: Text? = null, val count: UiNumber? = null, - val onClicked: (ClickContext.() -> Unit)? = null, val color: Color? = null, - ) : ActionMenu { + private val clickEvent: ClickEvent = ClickEvent.Noop, + ) : ActionMenu() { init { require(icon != null || text != null) { "icon and text cannot be both null" } } + val onClicked: ClickContext.() -> Unit by lazy { + clickEvent.onClicked + } + + @Serializable public enum class Color { Red, ContentColor, PrimaryColor, } - public enum class Icon { - Like, - Unlike, - Retweet, - Unretweet, - Reply, - Comment, - Quote, - Bookmark, - Unbookmark, - More, - MoreVerticel, - Delete, - Report, - React, - UnReact, - Share, - List, - ChatMessage, - Mute, - UnMute, - Block, - UnBlock, - } - @Immutable + @Serializable public sealed interface Text { @Immutable + @Serializable public data class Raw( val text: String, ) : Text @Immutable + @Serializable public data class Localized( + @SerialName("action_type") val type: Type, - val parameters: ImmutableList = persistentListOf(), + val parameters: SerializableImmutableList = persistentListOf(), ) : Text { + @Serializable public enum class Type { Like, Unlike, @@ -108,6 +103,8 @@ public sealed interface ActionMenu { UnBlock, BlockWithHandleParameter, MuteWithHandleParameter, + AcceptFollowRequest, + RejectFollowRequest, } } } @@ -121,25 +118,27 @@ internal fun userActionsMenu( ): List = listOfNotNull( ActionMenu.Item( - icon = ActionMenu.Item.Icon.Mute, + icon = UiIcon.Mute, text = ActionMenu.Item.Text.Localized( type = ActionMenu.Item.Text.Localized.Type.MuteWithHandleParameter, parameters = persistentListOf(handle), ), - onClicked = { - launcher.launch(DeeplinkRoute.MuteUser(accountKey, userKey)) - }, + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.MuteUser(accountKey, userKey), + ), ), ActionMenu.Item( - icon = ActionMenu.Item.Icon.Block, + icon = UiIcon.Block, text = ActionMenu.Item.Text.Localized( type = ActionMenu.Item.Text.Localized.Type.BlockWithHandleParameter, parameters = persistentListOf(handle), ), - onClicked = { - launcher.launch(DeeplinkRoute.BlockUser(accountKey, userKey)) - }, + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.BlockUser(accountKey, userKey), + ), ), ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/AuthenticatedMicroblogDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/AuthenticatedMicroblogDataSource.kt index 56f5b4326..6eebbbf10 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/AuthenticatedMicroblogDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/AuthenticatedMicroblogDataSource.kt @@ -1,73 +1,20 @@ package dev.dimension.flare.data.datasource.microblog -import androidx.paging.PagingData -import dev.dimension.flare.common.CacheData -import dev.dimension.flare.common.Cacheable +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiRelation -import dev.dimension.flare.ui.model.UiState -import dev.dimension.flare.ui.model.UiTimeline -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf +import dev.dimension.flare.ui.model.UiTimelineV2 -internal interface AuthenticatedMicroblogDataSource : - MicroblogDataSource, - StatusEvent { - fun notification( - type: NotificationFilter = NotificationFilter.All, - pageSize: Int = 20, - scope: CoroutineScope, - ): Flow> +internal interface AuthenticatedMicroblogDataSource : MicroblogDataSource { + val accountKey: MicroBlogKey - val supportedNotificationFilter: List + fun notification(type: NotificationFilter = NotificationFilter.All): RemoteLoader - fun relation(userKey: MicroBlogKey): Flow> + val supportedNotificationFilter: List suspend fun compose( data: ComposeData, progress: (ComposeProgress) -> Unit, ) - suspend fun deleteStatus(statusKey: MicroBlogKey) - fun composeConfig(type: ComposeType): ComposeConfig - - fun profileActions(): List - - suspend fun follow( - userKey: MicroBlogKey, - relation: UiRelation, - ) - - fun notificationBadgeCount(): CacheData = Cacheable({ }, { flowOf(0) }) } - -internal interface RelationDataSource { - suspend fun block(userKey: MicroBlogKey) - - suspend fun mute(userKey: MicroBlogKey) -} - -internal enum class ComposeType { - New, - Quote, - Reply, -} - -internal data class ComposeProgress( - val progress: Int, - val total: Int, -) { - val percent: Double - get() = progress.toDouble() / total.toDouble() -} - -public enum class NotificationFilter { - All, - Mention, - Comment, - Like, -} - -internal fun AuthenticatedMicroblogDataSource.relationKeyWithUserKey(userKey: MicroBlogKey) = "relation:$accountKey:$userKey" diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeData.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeData.kt index 78db39d66..c11a9e6cb 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeData.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeData.kt @@ -2,14 +2,14 @@ package dev.dimension.flare.data.datasource.microblog import dev.dimension.flare.common.FileItem import dev.dimension.flare.ui.model.UiAccount -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.presenter.compose.ComposeStatus public data class ComposeData( val account: UiAccount, val content: String, - val visibility: UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type = - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Public, + val visibility: UiTimelineV2.Post.Visibility = + UiTimelineV2.Post.Visibility.Public, val language: List = listOf("en"), val medias: List = emptyList(), val sensitive: Boolean = false, @@ -30,7 +30,7 @@ public data class ComposeData( ) public data class ReferenceStatus( - val data: UiTimeline?, + val data: UiTimelineV2?, val composeStatus: ComposeStatus, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeProgress.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeProgress.kt new file mode 100644 index 000000000..9f0319217 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeProgress.kt @@ -0,0 +1,9 @@ +package dev.dimension.flare.data.datasource.microblog + +internal data class ComposeProgress( + val progress: Int, + val total: Int, +) { + val percent: Double + get() = progress.toDouble() / total.toDouble() +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeType.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeType.kt new file mode 100644 index 000000000..7cdde3615 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeType.kt @@ -0,0 +1,7 @@ +package dev.dimension.flare.data.datasource.microblog + +internal enum class ComposeType { + New, + Quote, + Reply, +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/DatabaseUpdater.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/DatabaseUpdater.kt new file mode 100644 index 000000000..3bbb57ab4 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/DatabaseUpdater.kt @@ -0,0 +1,18 @@ +package dev.dimension.flare.data.datasource.microblog + +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 + +internal interface DatabaseUpdater { + suspend fun updateCache( + postKey: MicroBlogKey, + update: suspend (UiTimelineV2) -> UiTimelineV2, + ) + + suspend fun deleteFromCache(postKey: MicroBlogKey) + + suspend fun updateActionMenu( + postKey: MicroBlogKey, + newActionMenu: ActionMenu.Item, + ) +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MicroblogDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MicroblogDataSource.kt index dbd5e612c..01bba94c2 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MicroblogDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MicroblogDataSource.kt @@ -1,56 +1,35 @@ package dev.dimension.flare.data.datasource.microblog -import androidx.paging.PagingData -import dev.dimension.flare.common.CacheData -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineLoader +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiHashtag import dev.dimension.flare.ui.model.UiProfile -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow internal interface MicroblogDataSource { - fun homeTimeline(): BaseTimelineLoader - - fun userByAcct(acct: String): CacheData - - fun userById(id: String): CacheData + fun homeTimeline(): RemoteLoader fun userTimeline( userKey: MicroBlogKey, mediaOnly: Boolean = false, - ): BaseTimelineLoader + ): RemoteLoader - fun context(statusKey: MicroBlogKey): BaseTimelineLoader + fun context(statusKey: MicroBlogKey): RemoteLoader - fun status(statusKey: MicroBlogKey): CacheData + fun searchStatus(query: String): RemoteLoader - fun searchStatus(query: String): BaseTimelineLoader + fun searchUser(query: String): RemoteLoader - fun searchUser( - query: String, - pageSize: Int = 20, - ): Flow> + fun discoverUsers(): RemoteLoader - fun discoverUsers(pageSize: Int = 20): Flow> + fun discoverStatuses(): RemoteLoader - fun discoverStatuses(): BaseTimelineLoader + fun discoverHashtags(): RemoteLoader - fun discoverHashtags(pageSize: Int = 20): Flow> + fun following(userKey: MicroBlogKey): RemoteLoader - fun following( - userKey: MicroBlogKey, - scope: CoroutineScope, - pageSize: Int = 20, - ): Flow> - - fun fans( - userKey: MicroBlogKey, - scope: CoroutineScope, - pageSize: Int = 20, - ): Flow> + fun fans(userKey: MicroBlogKey): RemoteLoader fun profileTabs(userKey: MicroBlogKey): ImmutableList } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediator.kt index d6b1ddecb..2a3e20ce9 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediator.kt @@ -1,22 +1,20 @@ package dev.dimension.flare.data.datasource.microblog -import SnowflakeIdGenerator import androidx.paging.ExperimentalPagingApi import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.connect -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.ui.model.UiTimelineV2 import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope -import kotlin.uuid.Uuid internal class MixedRemoteMediator( private val database: CacheDatabase, - private val mediators: List, -) : BaseTimelineRemoteMediator(database = database) { + private val mediators: List>, +) : CacheableRemoteLoader { override val pagingKey = buildString { append("mixed_timeline") @@ -27,10 +25,10 @@ internal class MixedRemoteMediator( private var currentMediators = mediators @OptIn(ExperimentalPagingApi::class) - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult = + ): PagingResult = coroutineScope { if (request is PagingRequest.Prepend) { PagingResult(endOfPaginationReached = true) @@ -59,17 +57,7 @@ internal class MixedRemoteMediator( val mixedTimelineResult = timelineResult .sortedByDescending { - it.status.status.data.createdAt - .toEpochMilliseconds() - }.map { - it.copy( - timeline = - it.timeline.copy( - pagingKey = pagingKey, - sortId = -SnowflakeIdGenerator.nextId(), - _id = Uuid.random().toString(), - ), - ) + it.createdAt.value.toEpochMilliseconds() } database.connect { @@ -89,7 +77,7 @@ internal class MixedRemoteMediator( PagingResult( endOfPaginationReached = currentMediators.isEmpty(), - data = mixedTimelineResult + timelineResult, + data = mixedTimelineResult, nextKey = if (currentMediators.isEmpty()) null else "mixed_next_key", previousKey = null, ) @@ -98,13 +86,13 @@ internal class MixedRemoteMediator( private suspend fun getSubRequest( request: PagingRequest, - mediator: BaseTimelineRemoteMediator, + mediator: CacheableRemoteLoader, ): SubRequest? = when (request) { is PagingRequest.Append -> { database .pagingTimelineDao() - .getPagingKey(mediator.pagingKey) + .getPagingKey(subKey(mediator)) ?.nextKey ?.let(PagingRequest::Append) } @@ -112,7 +100,7 @@ internal class MixedRemoteMediator( is PagingRequest.Prepend -> database .pagingTimelineDao() - .getPagingKey(mediator.pagingKey) + .getPagingKey(subKey(mediator)) ?.prevKey ?.let(PagingRequest::Prepend) @@ -128,19 +116,19 @@ internal class MixedRemoteMediator( val (mediator, result) = subResponse if (request is PagingRequest.Prepend && result.previousKey != null) { database.pagingTimelineDao().updatePagingKeyPrevKey( - pagingKey = mediator.pagingKey, + pagingKey = subKey(mediator), prevKey = result.previousKey, ) } else if (request is PagingRequest.Append && result.nextKey != null) { database.pagingTimelineDao().updatePagingKeyNextKey( - pagingKey = mediator.pagingKey, + pagingKey = subKey(mediator), nextKey = result.nextKey, ) } else if (request is PagingRequest.Refresh) { - database.pagingTimelineDao().deletePagingKey(mediator.pagingKey) + database.pagingTimelineDao().deletePagingKey(subKey(mediator)) database.pagingTimelineDao().insertPagingKey( dev.dimension.flare.data.database.cache.model.DbPagingKey( - pagingKey = mediator.pagingKey, + pagingKey = subKey(mediator), nextKey = result.nextKey, prevKey = result.previousKey, ), @@ -148,15 +136,17 @@ internal class MixedRemoteMediator( } } + private fun subKey(mediator: CacheableRemoteLoader) = "mixed_${mediator.pagingKey}" + private data class SubRequest( - val mediator: BaseTimelineRemoteMediator, + val mediator: CacheableRemoteLoader, val request: PagingRequest, ) { - suspend fun load(pageSize: Int) = mediator.timeline(pageSize, request) + suspend fun load(pageSize: Int) = mediator.load(pageSize, request) } private data class SubResponse( - val mediator: BaseTimelineRemoteMediator, - val result: PagingResult, + val mediator: CacheableRemoteLoader, + val result: PagingResult, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/NotificationFilter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/NotificationFilter.kt new file mode 100644 index 000000000..3aa7f8f52 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/NotificationFilter.kt @@ -0,0 +1,8 @@ +package dev.dimension.flare.data.datasource.microblog + +public enum class NotificationFilter { + All, + Mention, + Comment, + Like, +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/Paging.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/Paging.kt index 46bd26409..e5fc7902f 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/Paging.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/Paging.kt @@ -1,205 +1,8 @@ package dev.dimension.flare.data.datasource.microblog -import androidx.paging.ExperimentalPagingApi -import androidx.paging.Pager import androidx.paging.PagingConfig -import androidx.paging.PagingData -import androidx.paging.PagingState -import androidx.paging.cachedIn -import androidx.paging.filter -import androidx.paging.map -import dev.dimension.flare.common.BasePagingSource -import dev.dimension.flare.common.BaseRemoteMediator -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator -import dev.dimension.flare.data.repository.AccountRepository -import dev.dimension.flare.model.AccountType -import dev.dimension.flare.ui.model.UiTimeline -import dev.dimension.flare.ui.model.mapper.render -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlin.coroutines.CoroutineContext internal val pagingConfig: PagingConfig = PagingConfig( pageSize = 20, ) - -@OptIn(ExperimentalPagingApi::class) -internal fun timelinePager( - pageSize: Int, - database: CacheDatabase, - scope: CoroutineScope, - filterFlow: Flow>, - mediator: BaseTimelineRemoteMediator, - accountRepository: AccountRepository, -): Flow> { - val pagerFlow = - Pager( - config = pagingConfig, - remoteMediator = mediator, - pagingSourceFactory = { - database.pagingTimelineDao().getPagingSource( - pagingKey = mediator.pagingKey, - ) - }, - ).flow.cachedIn(scope) - return combine( - pagerFlow, - filterFlow, - accountRepository.allAccounts, - ) { pagingData, filters, accounts -> - pagingData - .map { data -> - withContext(Dispatchers.IO) { - val dataSource = - when (data.timeline.accountType) { - AccountType.Guest -> null - is AccountType.Specific -> { - accounts.first { - it.accountKey == data.timeline.accountType.accountKey - } - } - }?.dataSource - data.render(dataSource) - } - }.filter { - !it.contains(filters) - } - }.cachedIn(scope) -} - -internal fun UiTimeline.contains(keywords: List): Boolean { - val text = - if (content is UiTimeline.ItemContent.Status) { - listOfNotNull( - content.content.raw, - content.contentWarning?.raw, - ) - } else { - emptyList() - } - return keywords.any { keyword -> - text.any { it.contains(keyword, ignoreCase = true) } - } -} - -internal class MemoryPagingSource( - private val key: String, - private val context: CoroutineContext, -) : BasePagingSource() { - companion object { - private val caches = mutableMapOf>>() - - fun update( - key: String, - value: ImmutableList, - ) { - caches[key]?.value = value - } - - fun append( - key: String, - value: ImmutableList, - ) { - @Suppress("UNCHECKED_CAST") - caches[key]?.value = ((caches[key]?.value as? ImmutableList ?: persistentListOf()) + value).toImmutableList() - } - - fun updateWith( - key: String, - update: (ImmutableList) -> ImmutableList, - ) { - @Suppress("UNCHECKED_CAST") - val value = caches[key]?.value as? ImmutableList ?: persistentListOf() - caches[key]?.value = update(value) - } - - @Suppress("UNCHECKED_CAST") - fun get(key: String): ImmutableList? = caches[key]?.value as? ImmutableList - - fun clear(key: String) { - caches.remove(key) - } - - @Suppress("UNCHECKED_CAST") - fun getFlow(key: String): Flow> = - caches - .getOrPut(key) { - MutableStateFlow(persistentListOf()) - } as Flow> - } - - // TODO: workaround for skip first invalidation to avoid loading infinite loop - private var skiped = false - - private val job = - caches - .getOrPut(key) { - MutableStateFlow(persistentListOf()) - }.let { - CoroutineScope(context).launch { - it.collectLatest { - if (!skiped) { - skiped = true - return@collectLatest - } - invalidate() - } - } - } - - init { - registerInvalidatedCallback { - job.cancel() - } - } - - override fun getRefreshKey(state: PagingState): Int? = - state.anchorPosition?.let { - maxOf(0, it - (state.config.initialLoadSize / 2)) - } - - override suspend fun doLoad(params: LoadParams): LoadResult { - val page = params.key ?: 0 - - @Suppress("UNCHECKED_CAST") - val list = caches[key]?.value as? ImmutableList ?: return LoadResult.Error(Exception("No data")) - val data = list.subList(page, (page + params.loadSize).coerceIn(0, list.size)) - val prevKey = (page - params.loadSize).takeIf { it in list.indices } - val nextKey = (page + params.loadSize).takeIf { it in list.indices } - return LoadResult.Page( - data = data, - prevKey = prevKey, - nextKey = nextKey, - ) - } -} - -@OptIn(ExperimentalPagingApi::class) -internal fun memoryPager( - pageSize: Int, - pagingKey: String, - scope: CoroutineScope, - mediator: BaseRemoteMediator, -): Flow> = - Pager( - config = pagingConfig, - remoteMediator = mediator, - pagingSourceFactory = { - MemoryPagingSource( - key = pagingKey, - context = Dispatchers.IO, - ) - }, - ).flow.cachedIn(scope) 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 new file mode 100644 index 000000000..e3ea5f1da --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/PostEvent.kt @@ -0,0 +1,381 @@ +package dev.dimension.flare.data.datasource.microblog + +import dev.dimension.flare.common.SerializableImmutableList +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.mastodonBookmark +import dev.dimension.flare.ui.model.mapper.mastodonLike +import dev.dimension.flare.ui.model.mapper.mastodonRepost +import dev.dimension.flare.ui.model.mapper.misskeyFavourite +import dev.dimension.flare.ui.model.mapper.misskeyReact +import dev.dimension.flare.ui.model.mapper.misskeyRenote +import dev.dimension.flare.ui.model.mapper.vvoFavorite +import dev.dimension.flare.ui.model.mapper.vvoLike +import dev.dimension.flare.ui.model.mapper.vvoLikeComment +import dev.dimension.flare.ui.model.mapper.xqtBookmark +import dev.dimension.flare.ui.model.mapper.xqtLike +import dev.dimension.flare.ui.model.mapper.xqtRetweet +import kotlinx.collections.immutable.toImmutableList +import kotlinx.serialization.Serializable + +@Serializable +internal sealed interface PostEvent { + val postKey: MicroBlogKey + + @Serializable + sealed interface PollEvent : PostEvent { + val accountKey: MicroBlogKey + val options: SerializableImmutableList + + fun copyWithOptions(options: List): PollEvent + } + + @Serializable + sealed interface Mastodon : PostEvent { + @Serializable + data class Reblog( + override val postKey: MicroBlogKey, + val reblogged: Boolean, + val count: Long, + val accountKey: MicroBlogKey, + ) : Mastodon, + UpdatePostActionMenuEvent { + override fun nextActionMenu(): ActionMenu.Item = + ActionMenu.mastodonRepost( + reblogged = !reblogged, + reblogsCount = count + if (!reblogged) 1 else -1, + accountKey = accountKey, + statusKey = postKey, + ) + } + + @Serializable + data class Like( + override val postKey: MicroBlogKey, + val liked: Boolean, + val accountKey: MicroBlogKey, + val count: Long, + ) : Mastodon, + UpdatePostActionMenuEvent { + override fun nextActionMenu(): ActionMenu.Item = + ActionMenu.mastodonLike( + favourited = !liked, + favouritesCount = count + if (!liked) 1 else -1, + accountKey = accountKey, + statusKey = postKey, + ) + } + + @Serializable + data class Bookmark( + override val postKey: MicroBlogKey, + val bookmarked: Boolean, + val accountKey: MicroBlogKey, + ) : Mastodon, + UpdatePostActionMenuEvent { + override fun nextActionMenu(): ActionMenu.Item = + ActionMenu.mastodonBookmark( + bookmarked = !bookmarked, + accountKey = accountKey, + statusKey = postKey, + ) + } + + @Serializable + data class Vote( + val id: String, + override val accountKey: MicroBlogKey, + override val postKey: MicroBlogKey, + override val options: SerializableImmutableList, + ) : Mastodon, + PollEvent { + override fun copyWithOptions(options: List): PollEvent = copy(options = options.toImmutableList()) + } + + @Serializable + data class AcceptFollowRequest( + override val postKey: MicroBlogKey, + val userKey: MicroBlogKey, + ) : Mastodon + + @Serializable + data class RejectFollowRequest( + override val postKey: MicroBlogKey, + val userKey: MicroBlogKey, + ) : Mastodon + } + + @Serializable + sealed interface Pleroma : PostEvent { + @Serializable + data class React( + override val postKey: MicroBlogKey, + val hasReacted: Boolean, + val reaction: String, + ) : Pleroma + } + + @Serializable + sealed interface Misskey : PostEvent { + @Serializable + data class React( + override val postKey: MicroBlogKey, + val hasReacted: Boolean, + val reaction: String, + val count: Long = 0, + val accountKey: MicroBlogKey? = null, + ) : Misskey, + UpdatePostActionMenuEvent { + override fun nextActionMenu(): ActionMenu.Item = + ActionMenu.misskeyReact( + postKey = postKey, + hasReacted = !hasReacted, + reaction = reaction, + count = (count + if (!hasReacted) 1 else -1).coerceAtLeast(0), + accountKey = accountKey, + ) + } + + @Serializable + data class Renote( + override val postKey: MicroBlogKey, + val count: Long = 0, + val accountKey: MicroBlogKey? = null, + ) : Misskey, + UpdatePostActionMenuEvent { + override fun nextActionMenu(): ActionMenu.Item = + ActionMenu.misskeyRenote( + postKey = postKey, + count = count + 1, + accountKey = accountKey, + ) + } + + @Serializable + data class Vote( + override val accountKey: MicroBlogKey, + override val postKey: MicroBlogKey, + override val options: SerializableImmutableList, + ) : Misskey, + PollEvent { + override fun copyWithOptions(options: List): PollEvent = copy(options = options.toImmutableList()) + } + + @Serializable + data class Favourite( + override val postKey: MicroBlogKey, + val favourited: Boolean, + val accountKey: MicroBlogKey? = null, + ) : Misskey, + UpdatePostActionMenuEvent { + override fun nextActionMenu(): ActionMenu.Item = + ActionMenu.misskeyFavourite( + postKey = postKey, + favourited = !favourited, + accountKey = accountKey, + ) + } + + @Serializable + data class AcceptFollowRequest( + override val postKey: MicroBlogKey, + val userKey: MicroBlogKey, + val notificationStatusKey: MicroBlogKey, + ) : Misskey + + @Serializable + data class RejectFollowRequest( + override val postKey: MicroBlogKey, + val userKey: MicroBlogKey, + val notificationStatusKey: MicroBlogKey, + ) : Misskey + } + + @Serializable + sealed interface Bluesky : PostEvent { + @Serializable + data class Reblog( + override val postKey: MicroBlogKey, + val count: Long, + val cid: String, + val uri: String, + val repostUri: String?, + val accountKey: MicroBlogKey, + ) : Bluesky, + UpdatePostActionMenuEvent { + override fun nextActionMenu(): ActionMenu.Item = + ActionMenu.blueskyReblog( + accountKey = accountKey, + postKey = postKey, + cid = cid, + uri = uri, + count = count + if (repostUri == null) 1 else -1, + repostUri = + if (repostUri == null) { + "" + } else { + null + }, + ) + } + + @Serializable + data class Like( + override val postKey: MicroBlogKey, + val cid: String, + val uri: String, + val likedUri: String?, + val count: Long, + val accountKey: MicroBlogKey, + ) : Bluesky, + UpdatePostActionMenuEvent { + override fun nextActionMenu(): ActionMenu.Item = + ActionMenu.blueskyLike( + accountKey = accountKey, + postKey = postKey, + cid = cid, + uri = uri, + count = count + if (likedUri == null) 1 else -1, + likedUri = + if (likedUri == null) { + "" + } else { + null + }, + ) + } + + @Serializable + data class Bookmark( + override val postKey: MicroBlogKey, + val uri: String, + val cid: String, + val bookmarked: Boolean, + val accountKey: MicroBlogKey, + val count: Long, + ) : Bluesky, + UpdatePostActionMenuEvent { + override fun nextActionMenu(): ActionMenu.Item = + ActionMenu.blueskyBookmark( + accountKey = accountKey, + postKey = postKey, + cid = cid, + uri = uri, + bookmarked = !bookmarked, + count = count + if (!bookmarked) 1 else -1, + ) + } + } + + @Serializable + sealed interface XQT : PostEvent { + @Serializable + data class Retweet( + override val postKey: MicroBlogKey, + val retweeted: Boolean, + val count: Long = 0, + val accountKey: MicroBlogKey, + ) : XQT, + UpdatePostActionMenuEvent { + override fun nextActionMenu(): ActionMenu.Item = + ActionMenu.xqtRetweet( + statusKey = postKey, + retweeted = !retweeted, + count = (count + if (!retweeted) 1 else -1).coerceAtLeast(0), + accountKey = accountKey, + ) + } + + @Serializable + data class Like( + override val postKey: MicroBlogKey, + val liked: Boolean, + val count: Long = 0, + val accountKey: MicroBlogKey, + ) : XQT, + UpdatePostActionMenuEvent { + override fun nextActionMenu(): ActionMenu.Item = + ActionMenu.xqtLike( + statusKey = postKey, + liked = !liked, + count = (count + if (!liked) 1 else -1).coerceAtLeast(0), + accountKey = accountKey, + ) + } + + @Serializable + data class Bookmark( + override val postKey: MicroBlogKey, + val bookmarked: Boolean, + val count: Long = 0, + val accountKey: MicroBlogKey, + ) : XQT, + UpdatePostActionMenuEvent { + override fun nextActionMenu(): ActionMenu.Item = + ActionMenu.xqtBookmark( + statusKey = postKey, + bookmarked = !bookmarked, + count = (count + if (!bookmarked) 1 else -1).coerceAtLeast(0), + accountKey = accountKey, + ) + } + } + + @Serializable + sealed interface VVO : PostEvent { + @Serializable + data class Like( + override val postKey: MicroBlogKey, + val liked: Boolean, + val count: Long = 0, + val accountKey: MicroBlogKey, + ) : VVO, + UpdatePostActionMenuEvent { + override fun nextActionMenu(): ActionMenu.Item = + ActionMenu.vvoLike( + statusKey = postKey, + liked = !liked, + count = (count + if (!liked) 1 else -1).coerceAtLeast(0), + accountKey = accountKey, + ) + } + + @Serializable + data class LikeComment( + override val postKey: MicroBlogKey, + val liked: Boolean, + val count: Long = 0, + val accountKey: MicroBlogKey, + ) : VVO, + UpdatePostActionMenuEvent { + override fun nextActionMenu(): ActionMenu.Item = + ActionMenu.vvoLikeComment( + statusKey = postKey, + liked = !liked, + count = (count + if (!liked) 1 else -1).coerceAtLeast(0), + accountKey = accountKey, + ) + } + + @Serializable + data class Favorite( + override val postKey: MicroBlogKey, + val favorited: Boolean, + val accountKey: MicroBlogKey, + ) : VVO, + UpdatePostActionMenuEvent { + override fun nextActionMenu(): ActionMenu.Item = + ActionMenu.vvoFavorite( + statusKey = postKey, + favorited = !favorited, + accountKey = accountKey, + ) + } + } +} + +internal interface UpdatePostActionMenuEvent : PostEvent { + fun nextActionMenu(): ActionMenu.Item +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ProfileAction.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ProfileAction.kt deleted file mode 100644 index 2d785f470..000000000 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ProfileAction.kt +++ /dev/null @@ -1,17 +0,0 @@ -package dev.dimension.flare.data.datasource.microblog - -import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiRelation - -public sealed interface ProfileAction { - public suspend operator fun invoke( - userKey: MicroBlogKey, - relation: UiRelation, - ) - - public fun relationState(relation: UiRelation): Boolean - - public interface Block : ProfileAction - - public interface Mute : ProfileAction -} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ProfileTab.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ProfileTab.kt index 81b5475c4..ebf772d05 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ProfileTab.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ProfileTab.kt @@ -1,14 +1,15 @@ package dev.dimension.flare.data.datasource.microblog import androidx.compose.runtime.Immutable -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineLoader +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader +import dev.dimension.flare.ui.model.UiTimelineV2 @Immutable public sealed interface ProfileTab { @Immutable public data class Timeline internal constructor( internal val type: Type, - internal val loader: BaseTimelineLoader, + internal val loader: RemoteLoader, ) : ProfileTab { @Immutable public enum class Type { diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ReactionDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ReactionDataSource.kt index c6073c0bc..61bf4bd58 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ReactionDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ReactionDataSource.kt @@ -1,17 +1,9 @@ package dev.dimension.flare.data.datasource.microblog -import dev.dimension.flare.common.Cacheable -import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiEmoji -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.ImmutableMap - internal interface ReactionDataSource : AuthenticatedMicroblogDataSource { - fun react( - statusKey: MicroBlogKey, - hasReacted: Boolean, - reaction: String, - ) - - fun emoji(): Cacheable>> +// fun react( +// statusKey: MicroBlogKey, +// hasReacted: Boolean, +// reaction: String, +// ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/StatusEvent.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/StatusEvent.kt deleted file mode 100644 index abc197f6c..000000000 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/StatusEvent.kt +++ /dev/null @@ -1,142 +0,0 @@ -package dev.dimension.flare.data.datasource.microblog - -import dev.dimension.flare.model.MicroBlogKey -import kotlinx.coroutines.flow.Flow - -internal sealed interface StatusEvent { - val accountKey: MicroBlogKey - - interface Mastodon : StatusEvent { - fun reblog( - statusKey: MicroBlogKey, - reblogged: Boolean, - ) - - fun like( - statusKey: MicroBlogKey, - liked: Boolean, - ) - - fun bookmark( - statusKey: MicroBlogKey, - bookmarked: Boolean, - ) - - fun vote( - statusKey: MicroBlogKey, - id: String, - options: List, - ) - - fun acceptFollowRequest( - userKey: MicroBlogKey, - notificationStatusKey: MicroBlogKey, - ) - - fun rejectFollowRequest( - userKey: MicroBlogKey, - notificationStatusKey: MicroBlogKey, - ) - } - - interface Pleroma : Mastodon { - fun react( - statusKey: MicroBlogKey, - hasReacted: Boolean, - reaction: String, - ) - } - - interface Misskey : StatusEvent { - fun react( - statusKey: MicroBlogKey, - hasReacted: Boolean, - reaction: String, - ) - - fun renote(statusKey: MicroBlogKey) - - fun vote( - statusKey: MicroBlogKey, - options: List, - ) - - fun favourite( - statusKey: MicroBlogKey, - favourited: Boolean, - ) - - fun favouriteState(statusKey: MicroBlogKey): Flow - - fun acceptFollowRequest( - userKey: MicroBlogKey, - notificationStatusKey: MicroBlogKey, - ) - - fun rejectFollowRequest( - userKey: MicroBlogKey, - notificationStatusKey: MicroBlogKey, - ) - } - - interface Bluesky : StatusEvent { - fun reblog( - statusKey: MicroBlogKey, - cid: String, - uri: String, - repostUri: String?, - ) - - fun like( - statusKey: MicroBlogKey, - cid: String, - uri: String, - likedUri: String?, - ) - - fun bookmark( - statusKey: MicroBlogKey, - uri: String, - cid: String, - ) - - fun unbookmark( - statusKey: MicroBlogKey, - uri: String, - ) - } - - interface XQT : StatusEvent { - fun retweet( - statusKey: MicroBlogKey, - retweeted: Boolean, - ) - - fun like( - statusKey: MicroBlogKey, - liked: Boolean, - ) - - fun bookmark( - statusKey: MicroBlogKey, - bookmarked: Boolean, - ) - } - - interface VVO : StatusEvent { - fun like( - statusKey: MicroBlogKey, - liked: Boolean, - ) - - fun likeComment( - statusKey: MicroBlogKey, - liked: Boolean, - ) - - fun favorite( - statusKey: MicroBlogKey, - favorited: Boolean, - ) - } -} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/datasource/ListDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/datasource/ListDataSource.kt new file mode 100644 index 000000000..595ee7850 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/datasource/ListDataSource.kt @@ -0,0 +1,13 @@ +package dev.dimension.flare.data.datasource.microblog.datasource + +import dev.dimension.flare.data.datasource.microblog.handler.ListHandler +import dev.dimension.flare.data.datasource.microblog.handler.ListMemberHandler +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader +import dev.dimension.flare.ui.model.UiTimelineV2 + +internal interface ListDataSource { + fun listTimeline(listId: String): RemoteLoader + + val listHandler: ListHandler + val listMemberHandler: ListMemberHandler +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/datasource/NotificationDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/datasource/NotificationDataSource.kt new file mode 100644 index 000000000..5be5b9972 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/datasource/NotificationDataSource.kt @@ -0,0 +1,7 @@ +package dev.dimension.flare.data.datasource.microblog.datasource + +import dev.dimension.flare.data.datasource.microblog.handler.NotificationHandler + +internal interface NotificationDataSource { + val notificationHandler: NotificationHandler +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/datasource/PostDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/datasource/PostDataSource.kt new file mode 100644 index 000000000..c228b446d --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/datasource/PostDataSource.kt @@ -0,0 +1,9 @@ +package dev.dimension.flare.data.datasource.microblog.datasource + +import dev.dimension.flare.data.datasource.microblog.handler.PostEventHandler +import dev.dimension.flare.data.datasource.microblog.handler.PostHandler + +internal interface PostDataSource { + val postHandler: PostHandler + val postEventHandler: PostEventHandler +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/datasource/RelationDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/datasource/RelationDataSource.kt new file mode 100644 index 000000000..b5804372d --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/datasource/RelationDataSource.kt @@ -0,0 +1,9 @@ +package dev.dimension.flare.data.datasource.microblog.datasource + +import dev.dimension.flare.data.datasource.microblog.handler.RelationHandler +import dev.dimension.flare.data.datasource.microblog.loader.RelationActionType + +internal interface RelationDataSource { + val relationHandler: RelationHandler + val supportedRelationTypes: Set +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/datasource/UserDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/datasource/UserDataSource.kt new file mode 100644 index 000000000..90d9be9eb --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/datasource/UserDataSource.kt @@ -0,0 +1,7 @@ +package dev.dimension.flare.data.datasource.microblog.datasource + +import dev.dimension.flare.data.datasource.microblog.handler.UserHandler + +internal interface UserDataSource { + val userHandler: UserHandler +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/EmojiHandler.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/EmojiHandler.kt new file mode 100644 index 000000000..ac636c646 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/EmojiHandler.kt @@ -0,0 +1,45 @@ +package dev.dimension.flare.data.datasource.microblog.handler + +import dev.dimension.flare.common.Cacheable +import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.model.DbEmoji +import dev.dimension.flare.data.database.cache.model.EmojiContent +import dev.dimension.flare.data.datasource.microblog.loader.EmojiLoader +import dev.dimension.flare.ui.model.UiEmoji +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.mapNotNull +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +internal class EmojiHandler( + private val host: String, + private val loader: EmojiLoader, +) : KoinComponent { + private val database: CacheDatabase by inject() + val emoji: Cacheable>> = + Cacheable( + fetchSource = { + val emojis = loader.emojis() + database.emojiDao().insert( + DbEmoji( + host = host, + content = + EmojiContent( + emojis, + ), + ), + ) + }, + cacheSource = { + database + .emojiDao() + .get(host) + .distinctUntilChanged() + .mapNotNull { + it?.content?.data + } + }, + ) +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListHandler.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/ListHandler.kt similarity index 96% rename from shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListHandler.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/ListHandler.kt index 40c110017..d1e662a9c 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListHandler.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/ListHandler.kt @@ -1,4 +1,4 @@ -package dev.dimension.flare.data.datasource.microblog.list +package dev.dimension.flare.data.datasource.microblog.handler import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager @@ -9,6 +9,9 @@ import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.connect import dev.dimension.flare.data.database.cache.model.DbList import dev.dimension.flare.data.database.cache.model.DbListPaging +import dev.dimension.flare.data.datasource.microblog.list.ListMetaData +import dev.dimension.flare.data.datasource.microblog.list.ListMetaDataType +import dev.dimension.flare.data.datasource.microblog.loader.ListLoader import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.createPagingRemoteMediator import dev.dimension.flare.data.datasource.microblog.pagingConfig diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListMemberHandler.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/ListMemberHandler.kt similarity index 91% rename from shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListMemberHandler.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/ListMemberHandler.kt index 342068e42..7e530e6e3 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListMemberHandler.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/ListMemberHandler.kt @@ -1,4 +1,4 @@ -package dev.dimension.flare.data.datasource.microblog.list +package dev.dimension.flare.data.datasource.microblog.handler import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager @@ -6,8 +6,11 @@ import androidx.paging.map import dev.dimension.flare.common.Cacheable import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.connect +import dev.dimension.flare.data.database.cache.mapper.toDbUser +import dev.dimension.flare.data.database.cache.mapper.upsertUsers import dev.dimension.flare.data.database.cache.model.DbList import dev.dimension.flare.data.database.cache.model.DbListMember +import dev.dimension.flare.data.datasource.microblog.loader.ListMemberLoader import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.createPagingRemoteMediator import dev.dimension.flare.data.datasource.microblog.pagingConfig @@ -15,7 +18,6 @@ import dev.dimension.flare.data.repository.tryRun import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.DbAccountType import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.mapper.render import kotlinx.coroutines.flow.map import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -54,11 +56,11 @@ internal class ListMemberHandler( data.map { item -> DbListMember( listKey = listKey, - memberKey = item.userKey, + memberKey = item.key, ) }, ) - database.userDao().insertAll(data) + database.upsertUsers(data.map { it.toDbUser() }) }, ), pagingSourceFactory = { @@ -68,7 +70,7 @@ internal class ListMemberHandler( }, ).flow.map { it.map { - it.user.render(accountKey) + it.user.content } } @@ -79,7 +81,7 @@ internal class ListMemberHandler( listKey = MicroBlogKey(listId, accountKey.host), ).map { members -> members.map { member -> - member.user.render(accountKey) + member.user.content } } @@ -100,8 +102,8 @@ internal class ListMemberHandler( ), ), ) - database.userDao().insertAll( - listOf(user), + database.upsertUsers( + listOf(user.toDbUser()), ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/NotificationHandler.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/NotificationHandler.kt new file mode 100644 index 000000000..8268592c5 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/NotificationHandler.kt @@ -0,0 +1,27 @@ +package dev.dimension.flare.data.datasource.microblog.handler + +import dev.dimension.flare.common.MemCacheable +import dev.dimension.flare.data.datasource.microblog.loader.NotificationLoader +import dev.dimension.flare.model.MicroBlogKey + +internal class NotificationHandler( + val accountKey: MicroBlogKey, + val loader: NotificationLoader, +) { + private val key by lazy { + "notificationHandler_$accountKey" + } + + val notificationBadgeCount by lazy { + MemCacheable( + key = key, + fetchSource = { + loader.notificationBadgeCount() + }, + ) + } + + fun clear() { + MemCacheable.update(key, 0) + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostEventHandler.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostEventHandler.kt new file mode 100644 index 000000000..97f4b0799 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostEventHandler.kt @@ -0,0 +1,200 @@ +package dev.dimension.flare.data.datasource.microblog.handler + +import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.connect +import dev.dimension.flare.data.datasource.microblog.ActionMenu +import dev.dimension.flare.data.datasource.microblog.DatabaseUpdater +import dev.dimension.flare.data.datasource.microblog.PostEvent +import dev.dimension.flare.data.datasource.microblog.UpdatePostActionMenuEvent +import dev.dimension.flare.data.repository.tryRun +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.DbAccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +internal class PostEventHandler( + private val accountType: AccountType, + private val handler: Handler, +) : KoinComponent, + DatabaseUpdater { + private val coroutineScope: CoroutineScope by inject() + private val database: CacheDatabase by inject() + private val dbAccountType = accountType as DbAccountType + + interface Handler { + suspend fun handle( + event: PostEvent, + updater: DatabaseUpdater, + ) + } + + fun handleEvent(event: PostEvent) { + coroutineScope.launch { + val originalData = + database + .statusDao() + .get( + statusKey = event.postKey, + accountType = dbAccountType, + ).firstOrNull() + ?.content + if (event is UpdatePostActionMenuEvent && originalData is UiTimelineV2.Post) { + val updatedData = + originalData.copy( + actions = + findAndReplaceActionMenu( + actions = originalData.actions, + newActionMenu = event.nextActionMenu(), + ), + ) + database.statusDao().update( + statusKey = event.postKey, + accountType = dbAccountType, + content = updatedData, + ) + } + if (event is PostEvent.PollEvent && originalData is UiTimelineV2.Post) { + val updatedData = + originalData.copy( + poll = + originalData.poll?.copy( + ownVotes = event.options, + options = + originalData.poll.options + .mapIndexed { index, option -> + if (event.options.contains(index)) { + option.copy( + votesCount = + option.votesCount.plus( + 1, + ), + ) + } else { + option + } + }.toImmutableList(), + ), + ) + database.statusDao().update( + statusKey = event.postKey, + accountType = dbAccountType, + content = updatedData, + ) + } + tryRun { + handler.handle( + event = event, + updater = this@PostEventHandler, + ) + }.onFailure { + // revert cache to original data if handling fails + if (originalData != null) { + database.statusDao().update( + statusKey = event.postKey, + accountType = dbAccountType, + content = originalData, + ) + } + } + } + } + + override suspend fun deleteFromCache(postKey: MicroBlogKey) { + database.connect { + database.statusDao().delete( + statusKey = postKey, + accountType = dbAccountType, + ) + database.pagingTimelineDao().deleteStatus( + accountType = dbAccountType, + statusKey = postKey, + ) + } + } + + override suspend fun updateCache( + postKey: MicroBlogKey, + update: suspend (UiTimelineV2) -> UiTimelineV2, + ) { + database.connect { + val currentData = + database + .statusDao() + .get( + statusKey = postKey, + accountType = dbAccountType, + ).firstOrNull() + ?.content + if (currentData != null) { + val updatedData = update(currentData) + database.statusDao().update( + statusKey = postKey, + accountType = dbAccountType, + content = updatedData, + ) + } + } + } + + override suspend fun updateActionMenu( + postKey: MicroBlogKey, + newActionMenu: ActionMenu.Item, + ) { + database.connect { + val currentData = + database + .statusDao() + .get( + statusKey = postKey, + accountType = dbAccountType, + ).firstOrNull() + ?.content + if (currentData != null && currentData is UiTimelineV2.Post) { + val updatedData = + currentData.copy( + actions = + findAndReplaceActionMenu( + actions = currentData.actions, + newActionMenu = newActionMenu, + ), + ) + database.statusDao().update( + statusKey = postKey, + accountType = dbAccountType, + content = updatedData, + ) + } + } + } + + private fun findAndReplaceActionMenu( + actions: ImmutableList, + newActionMenu: ActionMenu.Item, + ): ImmutableList = + actions + .map { + if (it is ActionMenu.Item && it.updateKey.isNotEmpty() && it.updateKey == newActionMenu.updateKey) { + newActionMenu + } else if (it is ActionMenu.Group) { + val updatedDisplayItem = + if (it.displayItem.updateKey.isNotEmpty() && it.displayItem.updateKey == newActionMenu.updateKey) { + newActionMenu + } else { + it.displayItem + } + it.copy( + displayItem = updatedDisplayItem, + actions = findAndReplaceActionMenu(it.actions, newActionMenu), + ) + } else { + it + } + }.toImmutableList() +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandler.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandler.kt new file mode 100644 index 000000000..1b6404729 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandler.kt @@ -0,0 +1,94 @@ +package dev.dimension.flare.data.datasource.microblog.handler + +import dev.dimension.flare.common.Cacheable +import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.connect +import dev.dimension.flare.data.database.cache.mapper.saveToDatabase +import dev.dimension.flare.data.database.cache.model.DbPagingTimeline +import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus +import dev.dimension.flare.data.datasource.microblog.loader.PostLoader +import dev.dimension.flare.data.datasource.microblog.paging.TimelinePagingMapper +import dev.dimension.flare.data.repository.tryRun +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.DbAccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +internal class PostHandler( + val accountType: AccountType, + val loader: PostLoader, +) : KoinComponent { + private val database: CacheDatabase by inject() + private val coroutineScope: CoroutineScope by inject() + + fun post(postKey: MicroBlogKey): Cacheable { + val pagingKey = "post_only_$postKey" + return Cacheable( + fetchSource = { + val result = loader.status(postKey) + database.connect { + val item = + TimelinePagingMapper.toDb( + result, + pagingKey, + ) + saveToDatabase(database, listOf(item)) + } + }, + cacheSource = { + val dbAccountType = accountType as DbAccountType + database + .statusDao() + .getWithReferences(postKey, dbAccountType) + .combine(database.pagingTimelineDao().get(pagingKey, accountType = dbAccountType)) { status, paging -> + when { + paging != null -> TimelinePagingMapper.toUi(paging, pagingKey, false) + status != null -> + TimelinePagingMapper.toUi( + DbPagingTimelineWithStatus( + timeline = + DbPagingTimeline( + pagingKey = pagingKey, + statusKey = postKey, + sortId = 0, + ), + status = status, + ), + pagingKey, + false, + ) + else -> null + } + }.distinctUntilChanged() + .mapNotNull { it } + }, + ) + } + + fun delete(postKey: MicroBlogKey) { + coroutineScope.launch { + tryRun { + loader.deleteStatus(postKey) + }.onSuccess { + database.connect { + database.statusDao().delete( + statusKey = postKey, + accountType = accountType as DbAccountType, + ) + database.statusReferenceDao().delete(postKey) + database.pagingTimelineDao().deleteStatus( + accountType = accountType as DbAccountType, + statusKey = postKey, + ) + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/RelationHandler.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/RelationHandler.kt new file mode 100644 index 000000000..cca502a8b --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/RelationHandler.kt @@ -0,0 +1,237 @@ +package dev.dimension.flare.data.datasource.microblog.handler + +import dev.dimension.flare.common.Cacheable +import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.model.DbUserRelation +import dev.dimension.flare.data.datasource.microblog.loader.RelationLoader +import dev.dimension.flare.data.repository.tryRun +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.DbAccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiRelation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +internal class RelationHandler( + val accountType: AccountType, + val dataSource: RelationLoader, +) : KoinComponent { + private val database: CacheDatabase by inject() + private val coroutineScope: CoroutineScope by inject() + + fun relation(userKey: MicroBlogKey) = + Cacheable( + fetchSource = { + val result = dataSource.relation(userKey) + database.userDao().insertUserRelation( + DbUserRelation( + accountType = accountType as DbAccountType, + userKey = userKey, + relation = result, + ), + ) + }, + cacheSource = { + database + .userDao() + .getUserRelation( + accountType = accountType as DbAccountType, + userKey = userKey, + ).mapNotNull { it?.relation } + }, + ) + + fun follow(userKey: MicroBlogKey) = + coroutineScope.launch { + tryRun { + updateRelation( + userKey = userKey, + update = { relation -> + relation.copy( + following = true, + ) + }, + ) + dataSource.follow(userKey) + }.onFailure { + updateRelation( + userKey = userKey, + update = { relation -> + relation.copy( + following = false, + ) + }, + ) + } + } + + fun unfollow(userKey: MicroBlogKey) = + coroutineScope.launch { + tryRun { + updateRelation( + userKey = userKey, + update = { relation -> + relation.copy( + following = false, + ) + }, + ) + dataSource.unfollow(userKey) + }.onFailure { + updateRelation( + userKey = userKey, + update = { relation -> + relation.copy( + following = true, + ) + }, + ) + } + } + + fun block(userKey: MicroBlogKey) = + coroutineScope.launch { + tryRun { + updateRelation( + userKey = userKey, + update = { relation -> + relation.copy( + blocking = true, + ) + }, + ) + dataSource.block(userKey) + }.onFailure { + updateRelation( + userKey = userKey, + update = { relation -> + relation.copy( + blocking = false, + ) + }, + ) + } + } + + fun unblock(userKey: MicroBlogKey) = + coroutineScope.launch { + tryRun { + updateRelation( + userKey = userKey, + update = { relation -> + relation.copy( + blocking = false, + ) + }, + ) + dataSource.unblock(userKey) + }.onFailure { + updateRelation( + userKey = userKey, + update = { relation -> + relation.copy( + blocking = true, + ) + }, + ) + } + } + + fun mute(userKey: MicroBlogKey) = + coroutineScope.launch { + tryRun { + updateRelation( + userKey = userKey, + update = { relation -> + relation.copy( + muted = true, + ) + }, + ) + dataSource.mute(userKey) + }.onFailure { + updateRelation( + userKey = userKey, + update = { relation -> + relation.copy( + muted = false, + ) + }, + ) + } + } + + fun unmute(userKey: MicroBlogKey) = + coroutineScope.launch { + tryRun { + updateRelation( + userKey = userKey, + update = { relation -> + relation.copy( + muted = false, + ) + }, + ) + dataSource.unmute(userKey) + }.onFailure { + updateRelation( + userKey = userKey, + update = { relation -> + relation.copy( + muted = true, + ) + }, + ) + } + } + + suspend fun approveFollowRequest(userKey: MicroBlogKey) { + updateRelation( + userKey = userKey, + update = { relation -> + relation.copy( + hasPendingFollowRequestToYou = false, + isFans = true, + ) + }, + ) + } + + suspend fun rejectFollowRequest(userKey: MicroBlogKey) { + updateRelation( + userKey = userKey, + update = { relation -> + relation.copy( + hasPendingFollowRequestToYou = false, + isFans = false, + ) + }, + ) + } + + private suspend fun updateRelation( + userKey: MicroBlogKey, + update: (UiRelation) -> UiRelation, + ) { + val currentRelation = + database + .userDao() + .getUserRelation( + accountType = accountType as DbAccountType, + userKey = userKey, + ).mapNotNull { it?.relation } + .firstOrNull() ?: return + val newRelation = update(currentRelation) + database.userDao().insertUserRelation( + DbUserRelation( + accountType = accountType as DbAccountType, + userKey = userKey, + relation = newRelation, + ), + ) + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandler.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandler.kt new file mode 100644 index 000000000..2b9c24dd1 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/UserHandler.kt @@ -0,0 +1,60 @@ +package dev.dimension.flare.data.datasource.microblog.handler + +import dev.dimension.flare.common.Cacheable +import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.mapper.toDbUser +import dev.dimension.flare.data.database.cache.mapper.upsertUser +import dev.dimension.flare.data.datasource.microblog.loader.UserLoader +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiHandle +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.mapNotNull +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +internal class UserHandler( + val host: String, + val loader: UserLoader, +) : KoinComponent { + private val database: CacheDatabase by inject() + + fun userByHandleAndHost(uiHandle: UiHandle) = + Cacheable( + fetchSource = { + val user = loader.userByHandleAndHost(uiHandle) + database.upsertUser( + user.toDbUser(), + ) + }, + cacheSource = { + database + .userDao() + .findByCanonicalHandleAndHost( + canonicalHandle = uiHandle.canonical, + host = uiHandle.normalizedHost, + ).distinctUntilChanged() + .mapNotNull { it?.content } + }, + ) + + fun userById(id: String) = + Cacheable( + fetchSource = { + val user = loader.userById(id) + database.upsertUser( + user.toDbUser(), + ) + }, + cacheSource = { + database + .userDao() + .findByKey( + MicroBlogKey( + id = id, + host = host, + ), + ).distinctUntilChanged() + .mapNotNull { it?.content } + }, + ) +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListDataSource.kt deleted file mode 100644 index c7b454834..000000000 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListDataSource.kt +++ /dev/null @@ -1,10 +0,0 @@ -package dev.dimension.flare.data.datasource.microblog.list - -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineLoader - -internal interface ListDataSource { - fun listTimeline(listId: String): BaseTimelineLoader - - val listHandler: ListHandler - val listMemberHandler: ListMemberHandler -} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/EmojiLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/EmojiLoader.kt new file mode 100644 index 000000000..e1e0efd06 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/EmojiLoader.kt @@ -0,0 +1,9 @@ +package dev.dimension.flare.data.datasource.microblog.loader + +import dev.dimension.flare.ui.model.UiEmoji +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap + +internal interface EmojiLoader { + suspend fun emojis(): ImmutableMap> +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/ListLoader.kt similarity index 76% rename from shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListLoader.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/ListLoader.kt index 5e3cabfd2..10b31d792 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListLoader.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/ListLoader.kt @@ -1,5 +1,7 @@ -package dev.dimension.flare.data.datasource.microblog.list +package dev.dimension.flare.data.datasource.microblog.loader +import dev.dimension.flare.data.datasource.microblog.list.ListMetaData +import dev.dimension.flare.data.datasource.microblog.list.ListMetaDataType import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.ui.model.UiList diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListMemberLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/ListMemberLoader.kt similarity index 81% rename from shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListMemberLoader.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/ListMemberLoader.kt index 1dee97911..d364f1cdd 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListMemberLoader.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/ListMemberLoader.kt @@ -1,22 +1,22 @@ -package dev.dimension.flare.data.datasource.microblog.list +package dev.dimension.flare.data.datasource.microblog.loader -import dev.dimension.flare.data.database.cache.model.DbUser import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiList +import dev.dimension.flare.ui.model.UiProfile internal interface ListMemberLoader { suspend fun loadMembers( pageSize: Int, request: PagingRequest, listId: String, - ): PagingResult + ): PagingResult suspend fun addMember( listId: String, userKey: MicroBlogKey, - ): DbUser + ): UiProfile suspend fun removeMember( listId: String, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/NotificationLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/NotificationLoader.kt new file mode 100644 index 000000000..c95d37796 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/NotificationLoader.kt @@ -0,0 +1,5 @@ +package dev.dimension.flare.data.datasource.microblog.loader + +internal interface NotificationLoader { + suspend fun notificationBadgeCount(): Int +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/PostLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/PostLoader.kt new file mode 100644 index 000000000..9ac4794fb --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/PostLoader.kt @@ -0,0 +1,10 @@ +package dev.dimension.flare.data.datasource.microblog.loader + +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 + +internal interface PostLoader { + suspend fun status(statusKey: MicroBlogKey): UiTimelineV2 + + suspend fun deleteStatus(statusKey: MicroBlogKey) +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/RelationLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/RelationLoader.kt new file mode 100644 index 000000000..c3638e723 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/RelationLoader.kt @@ -0,0 +1,28 @@ +package dev.dimension.flare.data.datasource.microblog.loader + +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiRelation + +internal enum class RelationActionType { + Follow, + Block, + Mute, +} + +internal interface RelationLoader { + val supportedTypes: Set + + suspend fun relation(userKey: MicroBlogKey): UiRelation + + suspend fun follow(userKey: MicroBlogKey) + + suspend fun unfollow(userKey: MicroBlogKey) + + suspend fun block(userKey: MicroBlogKey) + + suspend fun unblock(userKey: MicroBlogKey) + + suspend fun mute(userKey: MicroBlogKey) + + suspend fun unmute(userKey: MicroBlogKey) +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/UserLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/UserLoader.kt new file mode 100644 index 000000000..cac7ef9e3 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/UserLoader.kt @@ -0,0 +1,10 @@ +package dev.dimension.flare.data.datasource.microblog.loader + +import dev.dimension.flare.ui.model.UiHandle +import dev.dimension.flare.ui.model.UiProfile + +internal interface UserLoader { + suspend fun userByHandleAndHost(uiHandle: UiHandle): UiProfile + + suspend fun userById(id: String): UiProfile +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/BaseTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/BaseTimelineRemoteMediator.kt deleted file mode 100644 index 74cc588b9..000000000 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/BaseTimelineRemoteMediator.kt +++ /dev/null @@ -1,52 +0,0 @@ -package dev.dimension.flare.data.datasource.microblog.paging - -import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BasePagingSource -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.saveToDatabase -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.ui.model.UiTimeline - -internal sealed interface BaseTimelineLoader { - data object NotSupported : BaseTimelineLoader -} - -internal fun interface BaseTimelinePagingSourceFactory : BaseTimelineLoader { - abstract fun create(): BasePagingSource -} - -@OptIn(ExperimentalPagingApi::class) -internal abstract class BaseTimelineRemoteMediator( - private val database: CacheDatabase, -) : BasePagingRemoteMediator( - database = database, - ), - BaseTimelineLoader { - override suspend fun load( - pageSize: Int, - request: PagingRequest, - ): PagingResult = - timeline( - pageSize = pageSize, - request = request, - ) - - abstract suspend fun timeline( - pageSize: Int, - request: PagingRequest, - ): PagingResult - - override suspend fun onSaveCache( - request: PagingRequest, - data: List, - ) { - if (request is PagingRequest.Refresh) { - data.groupBy { it.timeline.pagingKey }.keys.forEach { key -> - database - .pagingTimelineDao() - .delete(pagingKey = key) - } - } - saveToDatabase(database, data) - } -} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/CacheableRemoteLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/CacheableRemoteLoader.kt new file mode 100644 index 000000000..25e5842a9 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/CacheableRemoteLoader.kt @@ -0,0 +1,5 @@ +package dev.dimension.flare.data.datasource.microblog.paging + +internal interface CacheableRemoteLoader : RemoteLoader { + val pagingKey: String +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/RemoteLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/RemoteLoader.kt new file mode 100644 index 000000000..10af34db5 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/RemoteLoader.kt @@ -0,0 +1,48 @@ +package dev.dimension.flare.data.datasource.microblog.paging + +import androidx.paging.PagingSource +import androidx.paging.PagingState + +internal interface RemoteLoader { + suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult +} + +internal fun notSupported(): RemoteLoader = NotSupportRemoteLoader() + +internal class NotSupportRemoteLoader : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult = PagingResult(endOfPaginationReached = true) +} + +internal fun RemoteLoader.toPagingSource() = + object : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + val request = + when (params) { + is LoadParams.Refresh -> PagingRequest.Refresh + is LoadParams.Prepend -> PagingRequest.Prepend(previousKey = params.key) + is LoadParams.Append -> PagingRequest.Append(nextKey = params.key) + } + return try { + val result = + load( + pageSize = params.loadSize, + request = request, + ) + LoadResult.Page( + data = result.data, + prevKey = result.previousKey, + nextKey = result.nextKey, + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } + + override fun getRefreshKey(state: PagingState): String? = null + } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelinePagingMapper.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelinePagingMapper.kt new file mode 100644 index 000000000..1e0b210bf --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelinePagingMapper.kt @@ -0,0 +1,270 @@ +package dev.dimension.flare.data.datasource.microblog.paging + +import SnowflakeIdGenerator +import dev.dimension.flare.data.database.cache.model.DbPagingTimeline +import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus +import dev.dimension.flare.data.database.cache.model.DbStatus +import dev.dimension.flare.data.database.cache.model.DbStatusReference +import dev.dimension.flare.data.database.cache.model.DbStatusReferenceWithStatus +import dev.dimension.flare.data.database.cache.model.DbStatusWithReference +import dev.dimension.flare.data.database.cache.model.DbStatusWithUser +import dev.dimension.flare.model.DbAccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.ReferenceType +import dev.dimension.flare.ui.model.UiTimelineV2 +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlin.uuid.Uuid + +internal object TimelinePagingMapper { + suspend fun toDb( + data: UiTimelineV2, + pagingKey: String, + ): DbPagingTimelineWithStatus = + DbPagingTimelineWithStatus( + timeline = + DbPagingTimeline( + pagingKey = pagingKey, + statusKey = data.statusKey, + sortId = -SnowflakeIdGenerator.nextId(), + ), + status = + DbStatusWithReference( + status = uiTimelineToDbStatusWithUser(data, sanitizePostReferences = true), + references = + when (data) { + is UiTimelineV2.Feed -> emptyList() + is UiTimelineV2.Message -> emptyList() + is UiTimelineV2.Post -> collectPostReferences(data, data.statusKey) + + is UiTimelineV2.User -> emptyList() + is UiTimelineV2.UserList -> + data.post + ?.let { + listOf( + uiTimelineToDbStatusReferenceWithStatus( + data = it, + referenceType = ReferenceType.Quote, + rootStatusKey = data.statusKey, + ), + ) + collectPostReferences(it, data.statusKey) + }.orEmpty() + }, + ), + ) + + fun toUi( + item: DbPagingTimelineWithStatus, + pagingKey: String, + useDbKeyInItemKey: Boolean, + ): UiTimelineV2 { + val root = dbStatusWithUserToUiTimeline(item.status.status, pagingKey, useDbKeyInItemKey) + val references = + item.status.references.mapNotNull { reference -> + reference.status?.let { + reference.reference.referenceType to + dbStatusWithUserToUiTimeline( + it, + pagingKey, + useDbKeyInItemKey, + ) + } + } + return when (root) { + is UiTimelineV2.Feed -> root + is UiTimelineV2.Message -> root + is UiTimelineV2.Post -> { + val resolvedRoot = + root.resolveReferences( + references = references, + ) + val repost = + (references.find { it.first == ReferenceType.Retweet }?.second as? UiTimelineV2.Post) + ?: resolvedRoot.internalRepost + val resolvedRepost = + repost?.resolveReferences( + references = references, + ) + if (resolvedRepost != null) { + resolvedRepost.copy( + internalRepost = resolvedRepost, + statusKey = resolvedRoot.statusKey, + message = resolvedRoot.message, + ) + } else { + resolvedRoot + } + } + is UiTimelineV2.User -> root + is UiTimelineV2.UserList -> + root.copy( + post = + root.post?.let { post -> + references.map { it.second }.find { it.statusKey == post.statusKey } as? UiTimelineV2.Post ?: post + }, + ) + } + } + + private fun UiTimelineV2.Post.resolveReferences(references: List>): UiTimelineV2.Post = + copy( + parents = resolveReferencePosts(ReferenceType.Reply, references, parents), + quote = resolveReferencePosts(ReferenceType.Quote, references, quote), + internalRepost = + resolveReferencePosts( + type = ReferenceType.Retweet, + references = references, + current = internalRepost?.let(::listOf).orEmpty(), + ).firstOrNull(), + ) + + private fun UiTimelineV2.Post.resolveReferencePosts( + type: ReferenceType, + references: List>, + current: List, + ) = if (current.isNotEmpty()) { + current + .map { currentPost -> + references + .find { it.first == type && it.second.statusKey == currentPost.statusKey } + ?.second as? UiTimelineV2.Post ?: currentPost + }.toImmutableList() + } else { + this.references + .asSequence() + .filter { it.type == type } + .mapNotNull { reference -> + references + .find { it.first == type && it.second.statusKey == reference.statusKey } + ?.second as? UiTimelineV2.Post + }.toList() + .toImmutableList() + } + + private fun uiTimelineToDbStatusReferenceWithStatus( + data: UiTimelineV2, + referenceType: ReferenceType, + rootStatusKey: MicroBlogKey, + ) = DbStatusReferenceWithStatus( + reference = + DbStatusReference( + referenceType = referenceType, + statusKey = rootStatusKey, + referenceStatusKey = data.statusKey, + _id = Uuid.random().toString(), + ), + status = uiTimelineToDbStatusWithUser(data, sanitizePostReferences = true), + ) + + private fun collectPostReferences( + data: UiTimelineV2.Post, + rootStatusKey: MicroBlogKey, + ): List { + val visited = mutableSetOf() + + fun visit(post: UiTimelineV2.Post): List = + post.directReferencePosts().flatMap { (referenceType, referencedPost) -> + listOf( + uiTimelineToDbStatusReferenceWithStatus( + data = referencedPost, + referenceType = referenceType, + rootStatusKey = rootStatusKey, + ), + ) + + if (visited.add(referencedPost.statusKey)) { + visit(referencedPost) + } else { + emptyList() + } + } + + return visit(data) + .distinctBy { + it.reference.referenceType to it.reference.referenceStatusKey + } + } + + private fun UiTimelineV2.Post.directReferencePosts(): List> = + quote.map { ReferenceType.Quote to it } + + parents.map { ReferenceType.Reply to it } + + listOfNotNull(internalRepost?.let { ReferenceType.Retweet to it }) + + private fun uiTimelineToDbStatusWithUser( + data: UiTimelineV2, + sanitizePostReferences: Boolean, + ): DbStatusWithUser = + DbStatusWithUser( + data = + DbStatus( + statusKey = data.statusKey, + content = if (sanitizePostReferences) data.sanitizeForDatabase() else data, + accountType = data.accountType as DbAccountType, + text = data.searchText, + ), + ) + + private fun UiTimelineV2.sanitizeForDatabase(): UiTimelineV2 = + when (this) { + is UiTimelineV2.Post -> + copy( + references = directReferences(), + quote = persistentListOf(), + parents = persistentListOf(), + internalRepost = null, + ) + else -> this + } + + private fun UiTimelineV2.Post.directReferences() = + ( + references + + quote.map { + UiTimelineV2.Post.Reference( + statusKey = it.statusKey, + type = ReferenceType.Quote, + ) + } + + parents.map { + UiTimelineV2.Post.Reference( + statusKey = it.statusKey, + type = ReferenceType.Reply, + ) + } + + listOfNotNull( + internalRepost?.let { + UiTimelineV2.Post.Reference( + statusKey = it.statusKey, + type = ReferenceType.Retweet, + ) + }, + ) + ).distinctBy { it.type to it.statusKey } + .toImmutableList() + + private fun dbStatusWithUserToUiTimeline( + data: DbStatusWithUser, + pagingKey: String, + useDbKeyInItemKey: Boolean, + ): UiTimelineV2 { + val root = data.data.content + return when (root) { + is UiTimelineV2.Feed -> root + is UiTimelineV2.Message -> + root.copy( + extraKey = if (useDbKeyInItemKey) pagingKey else null, + ) + is UiTimelineV2.Post -> + root.copy( + extraKey = if (useDbKeyInItemKey) pagingKey else null, + ) + is UiTimelineV2.User -> + root.copy( + extraKey = if (useDbKeyInItemKey) pagingKey else null, + ) + is UiTimelineV2.UserList -> + root.copy( + extraKey = if (useDbKeyInItemKey) pagingKey else null, + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelineRemoteMediator.kt new file mode 100644 index 000000000..de4ceeae4 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelineRemoteMediator.kt @@ -0,0 +1,127 @@ +package dev.dimension.flare.data.datasource.microblog.paging + +import androidx.paging.ExperimentalPagingApi +import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.mapper.saveToDatabase +import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import kotlinx.collections.immutable.toImmutableList + +@OptIn(ExperimentalPagingApi::class) +internal class TimelineRemoteMediator( + private val loader: CacheableRemoteLoader, + private val database: CacheDatabase, +) : BasePagingRemoteMediator( + database = database, + ), + RemoteLoader { + override val pagingKey: String + get() = loader.pagingKey + + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + val result = + timeline( + pageSize = pageSize, + request = request, + ) + val data = + result.data.map { + TimelinePagingMapper.toDb( + data = it, + pagingKey = pagingKey, + ) + } + return PagingResult( + data = data, + nextKey = result.nextKey, + previousKey = result.previousKey, + ) + } + + suspend fun timeline( + pageSize: Int, + request: PagingRequest, + ): PagingResult = + loader + .load( + pageSize = pageSize, + request = request, + ).let { result -> + result.copy( + data = result.data.collapseReplyChains(), + ) + } + + override suspend fun onSaveCache( + request: PagingRequest, + data: List, + ) { + if (request is PagingRequest.Refresh) { + data.groupBy { it.timeline.pagingKey }.keys.forEach { key -> + database + .pagingTimelineDao() + .delete(pagingKey = key) + } + } + saveToDatabase(database, data) + } +} + +private fun List.collapseReplyChains(): List { + val rootPosts = + asSequence() + .filterIsInstance() + .associateBy { it.accountType to it.statusKey } + if (rootPosts.isEmpty()) { + return this + } + + val collapsedPosts = mutableMapOf, UiTimelineV2.Post>() + val ancestorKeys = mutableSetOf>() + + fun collapse(post: UiTimelineV2.Post): UiTimelineV2.Post { + val key = post.accountType to post.statusKey + collapsedPosts[key]?.let { + return it + } + + val directParent = + post.parents + .lastOrNull() + ?.takeIf { rootPosts.containsKey(it.accountType to it.statusKey) } + ?.let { rootPosts.getValue(it.accountType to it.statusKey) } + + val collapsed = + if (directParent == null || directParent.accountType != post.accountType) { + post + } else { + ancestorKeys += directParent.accountType to directParent.statusKey + val collapsedParent = collapse(directParent) + post.copy( + parents = + ( + collapsedParent.parents + + listOf(collapsedParent) + + post.parents.dropLast(1) + ).distinctBy { it.statusKey } + .toImmutableList(), + ) + } + collapsedPosts[key] = collapsed + return collapsed + } + + return mapNotNull { item -> + if (item !is UiTimelineV2.Post) { + item + } else { + val key = item.accountType to item.statusKey + collapse(item).takeUnless { key in ancestorKeys } + } + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/AntennasListPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/AntennasListPagingSource.kt index e403b1c9e..b540fbf37 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/AntennasListPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/AntennasListPagingSource.kt @@ -1,7 +1,8 @@ package dev.dimension.flare.data.datasource.misskey -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.misskey.MisskeyService import dev.dimension.flare.data.repository.tryRun import dev.dimension.flare.ui.model.UiList @@ -9,24 +10,25 @@ import dev.dimension.flare.ui.model.mapper.render internal class AntennasListPagingSource( private val service: MisskeyService, -) : BasePagingSource() { - override suspend fun doLoad(params: LoadParams): LoadResult = - tryRun { - service.antennasList().map { - it.render() - } - }.fold( - onSuccess = { antennas -> - LoadResult.Page( - data = antennas, - prevKey = null, - nextKey = null, - ) - }, - onFailure = { error -> - LoadResult.Error(error) - }, +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend || request is PagingRequest.Append) { + return PagingResult( + endOfPaginationReached = true, + ) + } + val data = + tryRun { + service.antennasList().map { + it.render() + } + }.getOrThrow() + return PagingResult( + endOfPaginationReached = true, + data = data, ) - - override fun getRefreshKey(state: PagingState): Int? = null + } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/AntennasTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/AntennasTimelineRemoteMediator.kt index c0dfadbbb..26779526e 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/AntennasTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/AntennasTimelineRemoteMediator.kt @@ -1,31 +1,27 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.AntennasNotesRequest import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class AntennasTimelineRemoteMediator( private val service: MisskeyService, - database: CacheDatabase, private val accountKey: MicroBlogKey, private val id: String, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey = "antennas_${id}_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { @@ -59,10 +55,7 @@ internal class AntennasTimelineRemoteMediator( return PagingResult( endOfPaginationReached = response.isEmpty(), data = - response.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ), + response.render(accountKey), nextKey = response.lastOrNull()?.id, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/ChannelTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/ChannelTimelineRemoteMediator.kt index dd103d3d4..5adef84db 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/ChannelTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/ChannelTimelineRemoteMediator.kt @@ -1,29 +1,25 @@ package dev.dimension.flare.data.datasource.misskey -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.ChannelsTimelineRequest import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render internal class ChannelTimelineRemoteMediator( private val service: MisskeyService, - database: CacheDatabase, private val accountKey: MicroBlogKey, private val id: String, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey = "channel_${id}_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { @@ -59,10 +55,7 @@ internal class ChannelTimelineRemoteMediator( return PagingResult( endOfPaginationReached = response.isEmpty(), data = - response.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ), + response.render(accountKey), nextKey = response.lastOrNull()?.id, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/DiscoverStatusRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/DiscoverStatusRemoteMediator.kt index c7002f0d8..450f71690 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/DiscoverStatusRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/DiscoverStatusRemoteMediator.kt @@ -1,30 +1,26 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.NotesFeaturedRequest import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class DiscoverStatusRemoteMediator( private val service: MisskeyService, - database: CacheDatabase, private val accountKey: MicroBlogKey, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String = "discover_status_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { @@ -50,10 +46,7 @@ internal class DiscoverStatusRemoteMediator( return PagingResult( endOfPaginationReached = true, data = - response.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ), + response.render(accountKey), nextKey = response.lastOrNull()?.id, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/FansPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/FansPagingSource.kt index cca6dfacd..10e9459c3 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/FansPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/FansPagingSource.kt @@ -1,7 +1,8 @@ package dev.dimension.flare.data.datasource.misskey -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.UsersFollowersRequest import dev.dimension.flare.model.MicroBlogKey @@ -12,28 +13,36 @@ internal class FansPagingSource( private val service: MisskeyService, private val accountKey: MicroBlogKey, private val userKey: MicroBlogKey, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null - - override suspend fun doLoad(params: LoadParams): LoadResult { - val maxId = params.key - val limit = params.loadSize +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + val maxId = + when (request) { + PagingRequest.Refresh -> null + is PagingRequest.Prepend -> { + return PagingResult( + endOfPaginationReached = true, + ) + } + is PagingRequest.Append -> request.nextKey + } val response = service .usersFollowers( usersFollowersRequest = UsersFollowersRequest( untilId = maxId, - limit = limit, + limit = pageSize, userId = userKey.id, ), - ) - return LoadResult.Page( + ).orEmpty() + return PagingResult( data = - response.orEmpty().mapNotNull { + response.mapNotNull { it.follower?.render(accountKey = accountKey) }, - prevKey = null, nextKey = response.lastOrNull()?.id, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/FavouriteTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/FavouriteTimelineRemoteMediator.kt index 4b835565a..2b8a9b88b 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/FavouriteTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/FavouriteTimelineRemoteMediator.kt @@ -1,25 +1,20 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.AdminAdListRequest import dev.dimension.flare.model.MicroBlogKey -import kotlin.time.Instant +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class FavouriteTimelineRemoteMediator( private val accountKey: MicroBlogKey, private val service: MisskeyService, - database: CacheDatabase, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String get() = buildString { @@ -27,10 +22,10 @@ internal class FavouriteTimelineRemoteMediator( append(accountKey.toString()) } - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { is PagingRequest.Prepend -> return PagingResult( @@ -57,15 +52,7 @@ internal class FavouriteTimelineRemoteMediator( val notes = response.map { it.note } val data = - notes.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - sortIdProvider = { - response.find { note -> note.noteId == it.id }?.createdAt?.let { - Instant.parse(it).toEpochMilliseconds() - } ?: 0 - }, - ) + notes.render(accountKey) return PagingResult( endOfPaginationReached = response.isEmpty(), diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/FollowingPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/FollowingPagingSource.kt index cd7c15e0c..6d2e3ab02 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/FollowingPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/FollowingPagingSource.kt @@ -1,7 +1,8 @@ package dev.dimension.flare.data.datasource.misskey -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.UsersFollowersRequest import dev.dimension.flare.model.MicroBlogKey @@ -12,29 +13,36 @@ internal class FollowingPagingSource( private val service: MisskeyService, private val accountKey: MicroBlogKey, private val userKey: MicroBlogKey, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null - - override suspend fun doLoad(params: LoadParams): LoadResult { - val maxId = params.key - val limit = params.loadSize +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + val maxId = + when (request) { + PagingRequest.Refresh -> null + is PagingRequest.Prepend -> { + return PagingResult( + endOfPaginationReached = true, + ) + } + is PagingRequest.Append -> request.nextKey + } val response = service .usersFollowing( usersFollowersRequest = UsersFollowersRequest( untilId = maxId, - limit = limit, + limit = pageSize, userId = userKey.id, ), - ) - return LoadResult.Page( + ).orEmpty() + return PagingResult( data = - response.orEmpty().mapNotNull { - // TODO: isn't followee a typo? + response.mapNotNull { it.followee?.render(accountKey = accountKey) }, - prevKey = null, nextKey = response.lastOrNull()?.id, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/HomeTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/HomeTimelineRemoteMediator.kt index 7f27bfdda..95d17fb61 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/HomeTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/HomeTimelineRemoteMediator.kt @@ -1,32 +1,26 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.NotesHybridTimelineRequest import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class HomeTimelineRemoteMediator( private val accountKey: MicroBlogKey, private val service: MisskeyService, - database: CacheDatabase, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey = "home_$accountKey" - override suspend fun initialize(): InitializeAction = InitializeAction.SKIP_INITIAL_REFRESH - - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { is PagingRequest.Prepend -> return PagingResult( @@ -54,10 +48,7 @@ internal class HomeTimelineRemoteMediator( return PagingResult( endOfPaginationReached = response.isEmpty(), data = - response.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ), + response.render(accountKey), nextKey = response.lastOrNull()?.id, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/HybridTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/HybridTimelineRemoteMediator.kt index 5cd2f29e9..b31023aad 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/HybridTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/HybridTimelineRemoteMediator.kt @@ -1,28 +1,24 @@ package dev.dimension.flare.data.datasource.misskey -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.NotesHybridTimelineRequest import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render internal class HybridTimelineRemoteMediator( private val accountKey: MicroBlogKey, private val service: MisskeyService, - database: CacheDatabase, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey = "hybrid_timeline_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { is PagingRequest.Prepend -> return PagingResult( @@ -49,10 +45,7 @@ internal class HybridTimelineRemoteMediator( return PagingResult( endOfPaginationReached = response.isEmpty(), data = - response.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ), + response.render(accountKey), nextKey = response.lastOrNull()?.id, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/ListTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/ListTimelineRemoteMediator.kt index f06b9a4a4..093c894a3 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/ListTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/ListTimelineRemoteMediator.kt @@ -1,31 +1,27 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.NotesUserListTimelineRequest import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class ListTimelineRemoteMediator( private val listId: String, private val service: MisskeyService, - database: CacheDatabase, private val accountKey: MicroBlogKey, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey = "list_${accountKey}_$listId" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { @@ -63,10 +59,7 @@ internal class ListTimelineRemoteMediator( return PagingResult( endOfPaginationReached = response.isEmpty(), data = - response.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ), + response.render(accountKey), nextKey = response.lastOrNull()?.id, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/LocalTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/LocalTimelineRemoteMediator.kt index 9ba41549c..425aa56d5 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/LocalTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/LocalTimelineRemoteMediator.kt @@ -1,30 +1,26 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.NotesLocalTimelineRequest import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class LocalTimelineRemoteMediator( private val accountKey: MicroBlogKey, private val service: MisskeyService, - database: CacheDatabase, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey = "local_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { is PagingRequest.Prepend -> return PagingResult( @@ -51,10 +47,7 @@ internal class LocalTimelineRemoteMediator( return PagingResult( endOfPaginationReached = response.isEmpty(), data = - response.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ), + response.render(accountKey), nextKey = response.lastOrNull()?.id, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MentionTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MentionTimelineRemoteMediator.kt index 596559a4f..886ce40d0 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MentionTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MentionTimelineRemoteMediator.kt @@ -1,30 +1,26 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.NotesMentionsRequest import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class MentionTimelineRemoteMediator( private val accountKey: MicroBlogKey, private val service: MisskeyService, - database: CacheDatabase, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey = "mention_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { is PagingRequest.Prepend -> return PagingResult( @@ -51,10 +47,7 @@ internal class MentionTimelineRemoteMediator( return PagingResult( endOfPaginationReached = response.isEmpty(), data = - response.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ), + response.render(accountKey), nextKey = response.lastOrNull()?.id, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyChannelLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyChannelLoader.kt index 8b414bb61..0760cc53e 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyChannelLoader.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyChannelLoader.kt @@ -1,8 +1,8 @@ package dev.dimension.flare.data.datasource.misskey -import dev.dimension.flare.data.datasource.microblog.list.ListLoader import dev.dimension.flare.data.datasource.microblog.list.ListMetaData import dev.dimension.flare.data.datasource.microblog.list.ListMetaDataType +import dev.dimension.flare.data.datasource.microblog.loader.ListLoader import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.misskey.MisskeyService diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt index 063f884cf..56ababa9d 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt @@ -3,82 +3,68 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingData -import androidx.paging.PagingState import androidx.paging.cachedIn import androidx.paging.map -import dev.dimension.flare.common.BasePagingSource -import dev.dimension.flare.common.CacheData -import dev.dimension.flare.common.Cacheable import dev.dimension.flare.common.FileType -import dev.dimension.flare.common.MemCacheable -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.connect -import dev.dimension.flare.data.database.cache.mapper.Misskey -import dev.dimension.flare.data.database.cache.mapper.toDb -import dev.dimension.flare.data.database.cache.mapper.toDbUser -import dev.dimension.flare.data.database.cache.model.StatusContent -import dev.dimension.flare.data.database.cache.model.updateStatusUseCase import dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataSource import dev.dimension.flare.data.datasource.microblog.ComposeConfig import dev.dimension.flare.data.datasource.microblog.ComposeData import dev.dimension.flare.data.datasource.microblog.ComposeProgress import dev.dimension.flare.data.datasource.microblog.ComposeType +import dev.dimension.flare.data.datasource.microblog.DatabaseUpdater import dev.dimension.flare.data.datasource.microblog.NotificationFilter -import dev.dimension.flare.data.datasource.microblog.ProfileAction +import dev.dimension.flare.data.datasource.microblog.PostEvent import dev.dimension.flare.data.datasource.microblog.ProfileTab import dev.dimension.flare.data.datasource.microblog.ReactionDataSource -import dev.dimension.flare.data.datasource.microblog.RelationDataSource -import dev.dimension.flare.data.datasource.microblog.StatusEvent -import dev.dimension.flare.data.datasource.microblog.list.ListDataSource -import dev.dimension.flare.data.datasource.microblog.list.ListHandler -import dev.dimension.flare.data.datasource.microblog.list.ListLoader -import dev.dimension.flare.data.datasource.microblog.list.ListMemberHandler -import dev.dimension.flare.data.datasource.microblog.list.ListMemberLoader +import dev.dimension.flare.data.datasource.microblog.datasource.ListDataSource +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.UserDataSource +import dev.dimension.flare.data.datasource.microblog.handler.EmojiHandler +import dev.dimension.flare.data.datasource.microblog.handler.ListHandler +import dev.dimension.flare.data.datasource.microblog.handler.ListMemberHandler +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.ListLoader +import dev.dimension.flare.data.datasource.microblog.loader.ListMemberLoader +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader +import dev.dimension.flare.data.datasource.microblog.paging.notSupported +import dev.dimension.flare.data.datasource.microblog.paging.toPagingSource import dev.dimension.flare.data.datasource.microblog.pagingConfig -import dev.dimension.flare.data.datasource.microblog.relationKeyWithUserKey -import dev.dimension.flare.data.datasource.microblog.timelinePager import dev.dimension.flare.data.network.misskey.api.model.AdminAccountsDeleteRequest import dev.dimension.flare.data.network.misskey.api.model.ChannelsFeaturedRequest import dev.dimension.flare.data.network.misskey.api.model.ChannelsFollowRequest import dev.dimension.flare.data.network.misskey.api.model.IPinRequest -import dev.dimension.flare.data.network.misskey.api.model.MuteCreateRequest import dev.dimension.flare.data.network.misskey.api.model.NotesCreateRequest import dev.dimension.flare.data.network.misskey.api.model.NotesCreateRequestPoll import dev.dimension.flare.data.network.misskey.api.model.NotesPollsVoteRequest import dev.dimension.flare.data.network.misskey.api.model.NotesReactionsCreateRequest -import dev.dimension.flare.data.network.misskey.api.model.UsersShowRequest import dev.dimension.flare.data.repository.AccountRepository -import dev.dimension.flare.data.repository.LocalFilterRepository import dev.dimension.flare.data.repository.tryRun import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.model.PlatformType import dev.dimension.flare.shared.image.ImageCompressor +import dev.dimension.flare.ui.model.ClickEvent import dev.dimension.flare.ui.model.UiAccount -import dev.dimension.flare.ui.model.UiEmoji import dev.dimension.flare.ui.model.UiHashtag import dev.dimension.flare.ui.model.UiList +import dev.dimension.flare.ui.model.UiNumber import dev.dimension.flare.ui.model.UiProfile -import dev.dimension.flare.ui.model.UiRelation -import dev.dimension.flare.ui.model.UiState -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.model.mapper.render -import dev.dimension.flare.ui.model.mapper.toUi -import dev.dimension.flare.ui.model.toUi import dev.dimension.flare.ui.presenter.compose.ComposeStatus import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.toImmutableList -import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -87,14 +73,13 @@ internal class MisskeyDataSource( override val accountKey: MicroBlogKey, private val host: String, ) : AuthenticatedMicroblogDataSource, + UserDataSource, + PostDataSource, KoinComponent, - StatusEvent.Misskey, ListDataSource, ReactionDataSource, - RelationDataSource { - private val database: CacheDatabase by inject() - private val localFilterRepository: LocalFilterRepository by inject() - private val coroutineScope: CoroutineScope by inject() + RelationDataSource, + PostEventHandler.Handler { private val accountRepository: AccountRepository by inject() private val imageCompressor: ImageCompressor by inject() private val service by lazy { @@ -108,86 +93,253 @@ internal class MisskeyDataSource( ) } + private val loader by lazy { + MisskeyLoader( + accountKey = accountKey, + service = service, + ) + } + + private val emojiHandler by lazy { + EmojiHandler( + host = accountKey.host, + loader = loader, + ) + } + + 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 relationHandler by lazy { + RelationHandler( + accountType = AccountType.Specific(accountKey), + dataSource = loader, + ) + } + + override val supportedRelationTypes: Set + get() = loader.supportedTypes + + override val postEventHandler by lazy { + PostEventHandler( + accountType = AccountType.Specific(accountKey), + handler = this, + ) + } + + override suspend fun handle( + event: PostEvent, + updater: DatabaseUpdater, + ) { + require(event is PostEvent.Misskey) + when (event) { + is PostEvent.Misskey.React -> { + val reacted = !event.hasReacted + val nextActionCount = (event.count + if (reacted) 1 else -1).coerceAtLeast(0) + updater.updateCache(event.postKey) { current -> + if (current !is UiTimelineV2.Post) { + return@updateCache current + } + val updatedReactions = + current.emojiReactions + .toMutableList() + .let { reactions -> + val index = reactions.indexOfFirst { it.name == event.reaction } + if (reacted) { + if (index >= 0) { + val original = reactions[index] + reactions[index] = + original.copy( + count = UiNumber(original.count.value + 1), + me = true, + clickEvent = + ClickEvent.event( + accountKey, + PostEvent.Misskey.React( + postKey = event.postKey, + hasReacted = true, + reaction = event.reaction, + count = nextActionCount, + accountKey = accountKey, + ), + ), + ) + } else { + reactions.add( + UiTimelineV2.Post.EmojiReaction( + name = event.reaction, + url = "", + count = UiNumber(1), + clickEvent = + ClickEvent.event( + accountKey, + PostEvent.Misskey.React( + postKey = event.postKey, + hasReacted = true, + reaction = event.reaction, + count = nextActionCount, + accountKey = accountKey, + ), + ), + isUnicode = !event.reaction.startsWith(':') && !event.reaction.endsWith(':'), + me = true, + ), + ) + } + } else if (index >= 0) { + val original = reactions[index] + val newCount = (original.count.value - 1).coerceAtLeast(0) + if (newCount == 0L) { + reactions.removeAt(index) + } else { + reactions[index] = + original.copy( + count = UiNumber(newCount), + me = false, + clickEvent = + ClickEvent.event( + accountKey, + PostEvent.Misskey.React( + postKey = event.postKey, + hasReacted = false, + reaction = event.reaction, + count = nextActionCount, + accountKey = accountKey, + ), + ), + ) + } + } + reactions + }.sortedByDescending { it.count.value } + .toImmutableList() + current.copy( + emojiReactions = updatedReactions, + ) + } + if (event.hasReacted) { + service.notesReactionsDelete(IPinRequest(noteId = event.postKey.id)) + } else { + service.notesReactionsCreate( + NotesReactionsCreateRequest( + noteId = event.postKey.id, + reaction = event.reaction, + ), + ) + } + } + + is PostEvent.Misskey.Renote -> + service.notesCreate( + NotesCreateRequest( + renoteId = event.postKey.id, + ), + ) + + is PostEvent.Misskey.Vote -> + event.options.forEach { + service.notesPollsVote( + notesPollsVoteRequest = + NotesPollsVoteRequest( + noteId = event.postKey.id, + choice = it, + ), + ) + } + + is PostEvent.Misskey.Favourite -> { + if (event.favourited) { + service.notesFavoritesDelete(IPinRequest(noteId = event.postKey.id)) + } else { + service.notesFavoritesCreate(IPinRequest(noteId = event.postKey.id)) + } + } + + is PostEvent.Misskey.AcceptFollowRequest -> { + service.followingRequestsAccept( + adminAccountsDeleteRequest = AdminAccountsDeleteRequest(userId = event.userKey.id), + ) + updater.deleteFromCache(event.notificationStatusKey) + relationHandler.approveFollowRequest(event.userKey) + } + + is PostEvent.Misskey.RejectFollowRequest -> { + service.followingRequestsReject( + adminAccountsDeleteRequest = AdminAccountsDeleteRequest(userId = event.userKey.id), + ) + updater.deleteFromCache(event.notificationStatusKey) + relationHandler.rejectFollowRequest(event.userKey) + } + } + } + override fun homeTimeline() = HomeTimelineRemoteMediator( accountKey, service, - database, - ) - - fun localTimeline( - pageSize: Int = 20, - scope: CoroutineScope, - ): Flow> = - timelinePager( - pageSize = pageSize, - database = database, - scope = scope, - filterFlow = localFilterRepository.getFlow(forTimeline = true), - accountRepository = accountRepository, - mediator = localTimelineLoader(), ) fun localTimelineLoader() = LocalTimelineRemoteMediator( accountKey, service, - database, ) fun hybridTimelineLoader() = HybridTimelineRemoteMediator( accountKey, service, - database, - ) - - fun publicTimeline( - pageSize: Int = 20, - scope: CoroutineScope, - ): Flow> = - timelinePager( - pageSize = pageSize, - database = database, - scope = scope, - filterFlow = localFilterRepository.getFlow(forTimeline = true), - accountRepository = accountRepository, - mediator = publicTimelineLoader(), ) fun publicTimelineLoader() = PublicTimelineRemoteMediator( accountKey, service, - database, ) fun featuredChannels(scope: CoroutineScope): Flow> = Pager( config = pagingConfig, ) { - object : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null - - override suspend fun doLoad(params: LoadParams): LoadResult { - val result = - service - .channelsFeatured( - request = - ChannelsFeaturedRequest( - limit = params.loadSize, - ), + object : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult = + when (request) { + is PagingRequest.Prepend, + is PagingRequest.Append, + -> + PagingResult( + endOfPaginationReached = true, ) - return LoadResult.Page( - data = - result.map { - it.render(accountKey) - }, - prevKey = null, - nextKey = null, - ) - } - } + PagingRequest.Refresh -> + PagingResult( + endOfPaginationReached = true, + data = + service + .channelsFeatured( + request = + ChannelsFeaturedRequest( + limit = pageSize, + ), + ).map { + it.render(accountKey) + }, + ) + } + }.toPagingSource() }.flow .cachedIn(scope) .let { channels -> @@ -207,36 +359,22 @@ internal class MisskeyDataSource( } }.cachedIn(scope) - override fun notification( - type: NotificationFilter, - pageSize: Int, - scope: CoroutineScope, - ): Flow> = - timelinePager( - pageSize = pageSize, - database = database, - scope = scope, - filterFlow = localFilterRepository.getFlow(forNotification = true), - accountRepository = accountRepository, - mediator = - when (type) { - NotificationFilter.All -> - NotificationRemoteMediator( - accountKey, - service, - database, - ) + override fun notification(type: NotificationFilter): RemoteLoader = + when (type) { + NotificationFilter.All -> + NotificationRemoteMediator( + accountKey, + service, + ) - NotificationFilter.Mention -> - MentionTimelineRemoteMediator( - accountKey, - service, - database, - ) + NotificationFilter.Mention -> + MentionTimelineRemoteMediator( + accountKey, + service, + ) - else -> throw IllegalStateException("Unsupported notification type") - }, - ) + else -> notSupported() + } override val supportedNotificationFilter: List get() = @@ -245,63 +383,6 @@ internal class MisskeyDataSource( NotificationFilter.Mention, ) - override fun userByAcct(acct: String): CacheData { - val (name, host) = MicroBlogKey.valueOf(acct) - return Cacheable( - fetchSource = { - val user = - service - .usersShow(UsersShowRequest(username = name, host = host)) - .toDbUser(accountKey.host) - database.userDao().insert(user) - }, - cacheSource = { - database - .userDao() - .findByHandleAndHost(name, host, PlatformType.Misskey) - .distinctUntilChanged() - .mapNotNull { it?.render(accountKey) } - }, - ) - } - - override fun userById(id: String): CacheData { - val userKey = MicroBlogKey(id, accountKey.host) - return Cacheable( - fetchSource = { - val user = - service - .usersShow(UsersShowRequest(userId = id)) - .toDbUser(accountKey.host) - database.userDao().insert(user) - }, - cacheSource = { - database - .userDao() - .findByKey(userKey) - .distinctUntilChanged() - .mapNotNull { it?.render(accountKey) } - }, - ) - } - - override fun relation(userKey: MicroBlogKey): Flow> = - MemCacheable( - relationKeyWithUserKey(userKey), - ) { - val user = - service - .usersShow(UsersShowRequest(userId = userKey.id)) - UiRelation( - following = user.isFollowing ?: false, - isFans = user.isFollowed ?: false, - blocking = user.isBlocking ?: false, - muted = user.isMuted ?: false, - hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou ?: false, - hasPendingFollowRequestToYou = user.hasPendingFollowRequestToYou ?: false, - ) - }.toUi() - override fun userTimeline( userKey: MicroBlogKey, mediaOnly: Boolean, @@ -309,74 +390,17 @@ internal class MisskeyDataSource( accountKey, service, userKey, - database, onlyMedia = mediaOnly, ) override fun context(statusKey: MicroBlogKey) = StatusDetailRemoteMediator( statusKey, - database, accountKey, service, statusOnly = false, ) - override fun status(statusKey: MicroBlogKey): CacheData { - val pagingKey = "status_only_$statusKey" - return Cacheable( - fetchSource = { - val result = - service - .notesShow( - IPinRequest(noteId = statusKey.id), - ) - Misskey.save( - database = database, - accountKey = accountKey, - pagingKey = pagingKey, - data = listOfNotNull(result), - ) - }, - cacheSource = { - database - .statusDao() - .get(statusKey, AccountType.Specific(accountKey)) - .distinctUntilChanged() - .mapNotNull { it?.content?.render(this) } - }, - ) - } - - override fun emoji(): Cacheable>> = - Cacheable( - fetchSource = { - val emojis = - service - .emojis() - .emojis - .orEmpty() - .toImmutableList() - database.emojiDao().insert( - emojis.toDb(accountKey.host), - ) - }, - cacheSource = { - database - .emojiDao() - .get(accountKey.host) - .distinctUntilChanged() - .mapNotNull { - it - ?.toUi() - ?.groupBy { it.category } - ?.map { it.key to it.value.toImmutableList() } - ?.toMap() - ?.toImmutableMap() - } - }, - ) - override suspend fun compose( data: ComposeData, progress: (ComposeProgress) -> Unit, @@ -429,11 +453,11 @@ internal class MisskeyDataSource( text = data.content.takeIf { it.isNotEmpty() && it.isNotBlank() }, visibility = when (data.visibility) { - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Public -> "public" - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Home -> "home" - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Followers -> "followers" - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Specified -> "specified" - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Channel -> "public" + UiTimelineV2.Post.Visibility.Public -> "public" + UiTimelineV2.Post.Visibility.Home -> "home" + UiTimelineV2.Post.Visibility.Followers -> "followers" + UiTimelineV2.Post.Visibility.Specified -> "specified" + UiTimelineV2.Post.Visibility.Channel -> "public" }, renoteId = renoteId, replyId = inReplyToID, @@ -453,144 +477,6 @@ internal class MisskeyDataSource( // progress(ComposeProgress(maxProgress, maxProgress)) } - override fun renote(statusKey: MicroBlogKey) { - coroutineScope.launch { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - renoteCount = it.data.renoteCount + 1, - ), - ) - }, - ) - tryRun { - service.notesCreate( - NotesCreateRequest( - renoteId = statusKey.id, - ), - ) - }.onFailure { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - renoteCount = it.data.renoteCount - 1, - ), - ) - }, - ) - } - } - } - - override suspend fun deleteStatus(statusKey: MicroBlogKey) { - tryRun { - service.notesDelete( - IPinRequest( - noteId = statusKey.id, - ), - ) - - // delete status from cache - database.connect { - database.statusDao().delete( - statusKey = statusKey, - accountType = AccountType.Specific(accountKey), - ) - database.statusReferenceDao().delete(statusKey) - database.pagingTimelineDao().deleteStatus( - accountKey = accountKey, - statusKey = statusKey, - ) - } - } - } - - override fun react( - statusKey: MicroBlogKey, - hasReacted: Boolean, - reaction: String, - ) { - coroutineScope.launch { - updateStatusUseCase( - statusKey, - accountKey, - database, - ) { - it.copy( - data = - it.data.copy( - myReaction = if (hasReacted) null else reaction, - reactions = - it.data.reactions.toMutableMap().apply { - if (hasReacted) { - val current = it.data.reactions[reaction] ?: 0 - if (current > 1) { - put(reaction, current - 1) - } else { - remove(reaction) - } - } else { - put(reaction, it.data.reactions[reaction]?.plus(1) ?: 1) - } - }, - ), - ) - } - tryRun { - if (hasReacted) { - service.notesReactionsDelete( - IPinRequest( - noteId = statusKey.id, - ), - ) - } else { - service.notesReactionsCreate( - NotesReactionsCreateRequest( - noteId = statusKey.id, - reaction = reaction, - ), - ) - } - }.onFailure { - updateStatusUseCase( - statusKey, - accountKey, - database, - ) { - it.copy( - data = - it.data.copy( - myReaction = if (hasReacted) reaction else null, - reactions = - it.data.reactions.toMutableMap().apply { - if (hasReacted) { - put(reaction, it.data.reactions[reaction]?.plus(1) ?: 1) - } else { - val current = it.data.reactions[reaction] ?: 0 - if (current > 1) { - put(reaction, current - 1) - } else { - remove(reaction) - } - } - }, - ), - ) - } - } - } - } - suspend fun report( userKey: MicroBlogKey, statusKey: MicroBlogKey?, @@ -629,185 +515,36 @@ internal class MisskeyDataSource( } } - suspend fun unfollow(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - following = false, - ) - } - tryRun { - service.followingDelete(AdminAccountsDeleteRequest(userId = userKey.id)) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - following = true, - ) - } - } - } - - suspend fun follow(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - following = true, - ) - } - tryRun { - service.followingCreate(AdminAccountsDeleteRequest(userId = userKey.id)) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - following = false, - ) - } - } - } - - override suspend fun block(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - blocking = true, - ) - } - tryRun { - service.blockingCreate(AdminAccountsDeleteRequest(userId = userKey.id)) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - blocking = false, - ) - } - } - } - - suspend fun unblock(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - blocking = false, - ) - } - tryRun { - service.blockingDelete(AdminAccountsDeleteRequest(userId = userKey.id)) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - blocking = true, - ) - } - } - } - - override suspend fun mute(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - muted = true, - ) - } - tryRun { - service.muteCreate(MuteCreateRequest(userId = userKey.id)) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - muted = false, - ) - } - } - } - - suspend fun unmute(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - muted = false, - ) - } - tryRun { - service.muteDelete(AdminAccountsDeleteRequest(userId = userKey.id)) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - muted = true, - ) - } - } - } - override fun searchStatus(query: String) = SearchStatusRemoteMediator( service, - database, accountKey, query, ) - override fun searchUser( - query: String, - pageSize: Int, - ): Flow> = - Pager( - config = pagingConfig, - ) { - SearchUserPagingSource( - service, - accountKey, - query, - ) - }.flow + override fun searchUser(query: String): RemoteLoader = + SearchUserPagingSource( + service, + accountKey, + query, + ) - override fun discoverUsers(pageSize: Int): Flow> = - Pager( - config = pagingConfig, - ) { - TrendsUserPagingSource( - service, - accountKey, - ) - }.flow + override fun discoverUsers(): RemoteLoader = + TrendsUserPagingSource( + service, + accountKey, + ) override fun discoverStatuses() = DiscoverStatusRemoteMediator( service, - database, accountKey, ) - override fun discoverHashtags(pageSize: Int): Flow> = - Pager( - config = pagingConfig, - ) { - TrendHashtagPagingSource( - service, - ) - }.flow + override fun discoverHashtags(): RemoteLoader = + TrendHashtagPagingSource( + service, + ) override fun composeConfig(type: ComposeType): ComposeConfig = ComposeConfig( @@ -820,149 +557,12 @@ internal class MisskeyDataSource( allowMediaOnly = true, ), poll = ComposeConfig.Poll(9), - emoji = ComposeConfig.Emoji(emoji(), "misskey@${accountKey.host}"), + emoji = ComposeConfig.Emoji(emojiHandler.emoji, "misskey@${accountKey.host}"), contentWarning = ComposeConfig.ContentWarning, visibility = ComposeConfig.Visibility, ) - override suspend fun follow( - userKey: MicroBlogKey, - relation: UiRelation, - ) { - when { - relation.following -> unfollow(userKey) - relation.blocking -> unblock(userKey) - relation.hasPendingFollowRequestFromYou -> Unit // TODO: cancel follow request - else -> follow(userKey) - } - } - - override fun profileActions(): List = - listOf( - object : ProfileAction.Mute { - override suspend fun invoke( - userKey: MicroBlogKey, - relation: UiRelation, - ) { - if (relation.muted) { - unmute(userKey) - } else { - mute(userKey) - } - } - - override fun relationState(relation: UiRelation): Boolean = relation.muted - }, - object : ProfileAction.Block { - override suspend fun invoke( - userKey: MicroBlogKey, - relation: UiRelation, - ) { - if (relation.blocking) { - unblock(userKey) - } else { - block(userKey) - } - } - - override fun relationState(relation: UiRelation): Boolean = relation.blocking - }, - ) - - override fun vote( - statusKey: MicroBlogKey, - options: List, - ) { - coroutineScope.launch { - updateStatusUseCase( - statusKey, - accountKey, - database, - ) { - it.copy( - data = - it.data.copy( - poll = - it.data.poll?.copy( - choices = - it.data.poll.choices.mapIndexed { index, choice -> - if (options.contains(index)) { - choice.copy( - votes = choice.votes + 1, - isVoted = true, - ) - } else { - choice - } - }, - ), - ), - ) - } - tryRun { - options.forEach { - service.notesPollsVote( - notesPollsVoteRequest = - NotesPollsVoteRequest( - noteId = statusKey.id, - choice = it, - ), - ) - } - }.onFailure { - updateStatusUseCase( - statusKey, - accountKey, - database, - ) { - it.copy( - data = - it.data.copy( - poll = - it.data.poll?.copy( - choices = - it.data.poll.choices.mapIndexed { index, choice -> - if (options.contains(index)) { - choice.copy( - votes = choice.votes - 1, - isVoted = false, - ) - } else { - choice - } - }, - ), - ), - ) - } - } - } - } - - override fun favourite( - statusKey: MicroBlogKey, - favourited: Boolean, - ) { - coroutineScope.launch { - tryRun { - if (favourited) { - service.notesFavoritesDelete( - IPinRequest( - noteId = statusKey.id, - ), - ) - } else { - service.notesFavoritesCreate( - IPinRequest( - noteId = statusKey.id, - ), - ) - } - } - } - } - - override fun favouriteState(statusKey: MicroBlogKey): Flow = + fun favouriteState(statusKey: MicroBlogKey): Flow = flow { tryRun { service.notesState( @@ -980,35 +580,19 @@ internal class MisskeyDataSource( ) } - override fun following( - userKey: MicroBlogKey, - scope: CoroutineScope, - pageSize: Int, - ): Flow> = - Pager( - config = pagingConfig, - ) { - FollowingPagingSource( - service = service, - userKey = userKey, - accountKey = accountKey, - ) - }.flow.cachedIn(scope) + override fun following(userKey: MicroBlogKey): RemoteLoader = + FollowingPagingSource( + service = service, + userKey = userKey, + accountKey = accountKey, + ) - override fun fans( - userKey: MicroBlogKey, - scope: CoroutineScope, - pageSize: Int, - ): Flow> = - Pager( - config = pagingConfig, - ) { - FansPagingSource( - service = service, - userKey = userKey, - accountKey = accountKey, - ) - }.flow.cachedIn(scope) + override fun fans(userKey: MicroBlogKey): RemoteLoader = + FansPagingSource( + service = service, + userKey = userKey, + accountKey = accountKey, + ) override fun profileTabs(userKey: MicroBlogKey): ImmutableList = listOfNotNull( @@ -1019,7 +603,6 @@ internal class MisskeyDataSource( accountKey = accountKey, service = service, userKey = userKey, - database = database, withPinned = true, ), ), @@ -1029,7 +612,6 @@ internal class MisskeyDataSource( UserTimelineRemoteMediator( service = service, accountKey = accountKey, - database = database, userKey = userKey, withReplies = true, ), @@ -1037,23 +619,9 @@ internal class MisskeyDataSource( ProfileTab.Media, ).toPersistentList() - fun favouriteTimeline( - pageSize: Int = 20, - scope: CoroutineScope, - ): Flow> = - timelinePager( - pageSize = pageSize, - database = database, - scope = scope, - filterFlow = localFilterRepository.getFlow(forTimeline = true), - accountRepository = accountRepository, - mediator = favouriteTimelineLoader(), - ) - fun favouriteTimelineLoader() = FavouriteTimelineRemoteMediator( service = service, - database = database, accountKey = accountKey, ) @@ -1064,7 +632,6 @@ internal class MisskeyDataSource( ListTimelineRemoteMediator( listId, service, - database, accountKey, ) @@ -1173,95 +740,18 @@ internal class MisskeyDataSource( } } - override fun acceptFollowRequest( - userKey: MicroBlogKey, - notificationStatusKey: MicroBlogKey, - ) { - coroutineScope.launch { - tryRun { - MemCacheable.updateWith( - key = relationKeyWithUserKey(userKey), - ) { - it.copy( - hasPendingFollowRequestToYou = false, - isFans = true, - ) - } - service.followingRequestsAccept( - adminAccountsDeleteRequest = - AdminAccountsDeleteRequest( - userId = userKey.id, - ), - ) - }.onFailure { - MemCacheable.updateWith( - key = relationKeyWithUserKey(userKey), - ) { - it.copy( - hasPendingFollowRequestToYou = true, - isFans = false, - ) - } - }.onSuccess { - database.pagingTimelineDao().deleteStatus( - accountKey = accountKey, - statusKey = notificationStatusKey, - ) - } - } - } - - override fun rejectFollowRequest( - userKey: MicroBlogKey, - notificationStatusKey: MicroBlogKey, - ) { - coroutineScope.launch { - tryRun { - MemCacheable.updateWith( - key = relationKeyWithUserKey(userKey), - ) { - it.copy( - hasPendingFollowRequestToYou = false, - isFans = false, - ) - } - service.followingRequestsReject( - adminAccountsDeleteRequest = - AdminAccountsDeleteRequest( - userId = userKey.id, - ), - ) - }.onFailure { - MemCacheable.updateWith( - key = relationKeyWithUserKey(userKey), - ) { - it.copy( - hasPendingFollowRequestToYou = true, - isFans = false, - ) - } - }.onSuccess { - database.pagingTimelineDao().deleteStatus( - accountKey = accountKey, - statusKey = notificationStatusKey, - ) - } - } - } - fun antennasList(): Flow> = Pager( config = pagingConfig, ) { AntennasListPagingSource( service = service, - ) + ).toPagingSource() }.flow fun antennasTimelineLoader(id: String) = AntennasTimelineRemoteMediator( service = service, - database = database, accountKey = accountKey, id = id, ) @@ -1269,7 +759,6 @@ internal class MisskeyDataSource( fun channelTimelineLoader(id: String) = ChannelTimelineRemoteMediator( service = service, - database = database, accountKey = accountKey, id = id, ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyListLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyListLoader.kt index 4a48ef8b4..d34a13aef 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyListLoader.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyListLoader.kt @@ -1,8 +1,8 @@ package dev.dimension.flare.data.datasource.misskey -import dev.dimension.flare.data.datasource.microblog.list.ListLoader import dev.dimension.flare.data.datasource.microblog.list.ListMetaData import dev.dimension.flare.data.datasource.microblog.list.ListMetaDataType +import dev.dimension.flare.data.datasource.microblog.loader.ListLoader import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.misskey.MisskeyService diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyListMemberLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyListMemberLoader.kt index 3b7fc3f42..4ffc8fad5 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyListMemberLoader.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyListMemberLoader.kt @@ -1,8 +1,6 @@ package dev.dimension.flare.data.datasource.misskey -import dev.dimension.flare.data.database.cache.mapper.toDbUser -import dev.dimension.flare.data.database.cache.model.DbUser -import dev.dimension.flare.data.datasource.microblog.list.ListMemberLoader +import dev.dimension.flare.data.datasource.microblog.loader.ListMemberLoader import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.misskey.MisskeyService @@ -12,6 +10,7 @@ import dev.dimension.flare.data.network.misskey.api.model.UsersListsPullRequest import dev.dimension.flare.data.network.misskey.api.model.UsersShowRequest import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiList +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.mapper.render import kotlinx.collections.immutable.toImmutableList @@ -23,7 +22,7 @@ internal class MisskeyListMemberLoader( pageSize: Int, request: PagingRequest, listId: String, - ): PagingResult { + ): PagingResult { val cursor = when (request) { is PagingRequest.Append -> request.nextKey @@ -43,7 +42,7 @@ internal class MisskeyListMemberLoader( val users = response.map { - it.user.toDbUser(accountKey.host) + it.user.render(accountKey) } return PagingResult( @@ -55,7 +54,7 @@ internal class MisskeyListMemberLoader( override suspend fun addMember( listId: String, userKey: MicroBlogKey, - ): DbUser { + ): UiProfile { service.usersListsPush( UsersListsPullRequest( listId = listId, @@ -67,7 +66,7 @@ internal class MisskeyListMemberLoader( UsersShowRequest( userId = userKey.id, ), - ).toDbUser(accountKey.host) + ).render(accountKey) } override suspend fun removeMember( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyLoader.kt new file mode 100644 index 000000000..b8b612243 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyLoader.kt @@ -0,0 +1,113 @@ +package dev.dimension.flare.data.datasource.misskey + +import dev.dimension.flare.data.datasource.microblog.loader.EmojiLoader +import dev.dimension.flare.data.datasource.microblog.loader.PostLoader +import dev.dimension.flare.data.datasource.microblog.loader.RelationActionType +import dev.dimension.flare.data.datasource.microblog.loader.RelationLoader +import dev.dimension.flare.data.datasource.microblog.loader.UserLoader +import dev.dimension.flare.data.network.misskey.MisskeyService +import dev.dimension.flare.data.network.misskey.api.model.AdminAccountsDeleteRequest +import dev.dimension.flare.data.network.misskey.api.model.IPinRequest +import dev.dimension.flare.data.network.misskey.api.model.MuteCreateRequest +import dev.dimension.flare.data.network.misskey.api.model.UsersShowRequest +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiEmoji +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 dev.dimension.flare.ui.model.mapper.render +import dev.dimension.flare.ui.model.mapper.toUi +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap + +internal class MisskeyLoader( + val accountKey: MicroBlogKey, + private val service: MisskeyService, +) : UserLoader, + PostLoader, + RelationLoader, + EmojiLoader { + override val supportedTypes: Set = + setOf( + RelationActionType.Follow, + RelationActionType.Block, + RelationActionType.Mute, + ) + + override suspend fun userByHandleAndHost(uiHandle: UiHandle): UiProfile = + service + .usersShow( + UsersShowRequest( + username = uiHandle.normalizedRaw, + host = uiHandle.normalizedHost, + ), + ).render(accountKey) + + override suspend fun userById(id: String): UiProfile = service.usersShow(UsersShowRequest(userId = id)).render(accountKey) + + override suspend fun status(statusKey: MicroBlogKey): UiTimelineV2 = + service + .notesShow( + IPinRequest(noteId = statusKey.id), + ).render(accountKey) + + override suspend fun deleteStatus(statusKey: MicroBlogKey) { + service.notesDelete( + IPinRequest( + noteId = statusKey.id, + ), + ) + } + + override suspend fun relation(userKey: MicroBlogKey): UiRelation { + val user = service.usersShow(UsersShowRequest(userId = userKey.id)) + return UiRelation( + following = user.isFollowing ?: false, + isFans = user.isFollowed ?: false, + blocking = user.isBlocking ?: false, + muted = user.isMuted ?: false, + hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou ?: false, + hasPendingFollowRequestToYou = user.hasPendingFollowRequestToYou ?: false, + ) + } + + override suspend fun follow(userKey: MicroBlogKey) { + service.followingCreate(AdminAccountsDeleteRequest(userId = userKey.id)) + } + + override suspend fun unfollow(userKey: MicroBlogKey) { + service.followingDelete(AdminAccountsDeleteRequest(userId = userKey.id)) + } + + override suspend fun block(userKey: MicroBlogKey) { + service.blockingCreate(AdminAccountsDeleteRequest(userId = userKey.id)) + } + + override suspend fun unblock(userKey: MicroBlogKey) { + service.blockingDelete(AdminAccountsDeleteRequest(userId = userKey.id)) + } + + override suspend fun mute(userKey: MicroBlogKey) { + service.muteCreate(MuteCreateRequest(userId = userKey.id)) + } + + override suspend fun unmute(userKey: MicroBlogKey) { + service.muteDelete(AdminAccountsDeleteRequest(userId = userKey.id)) + } + + override suspend fun emojis(): ImmutableMap> = + service + .emojis() + .emojis + .orEmpty() + .map { + it.toUi() + }.groupBy { it.category } + .map { (category, value) -> + category to value.toImmutableList() + }.toMap() + .toImmutableMap() +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/NotificationRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/NotificationRemoteMediator.kt index 7aba2c3d9..993098c26 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/NotificationRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/NotificationRemoteMediator.kt @@ -1,30 +1,26 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDb -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.INotificationsRequest import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class NotificationRemoteMediator( private val accountKey: MicroBlogKey, private val service: MisskeyService, - database: CacheDatabase, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey = "notification_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { is PagingRequest.Prepend -> return PagingResult( @@ -51,10 +47,7 @@ internal class NotificationRemoteMediator( return PagingResult( endOfPaginationReached = response.isEmpty(), data = - response.toDb( - accountKey = accountKey, - pagingKey = pagingKey, - ), + response.render(accountKey), nextKey = response.lastOrNull()?.id, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/PublicTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/PublicTimelineRemoteMediator.kt index 0e54e4601..061600df0 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/PublicTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/PublicTimelineRemoteMediator.kt @@ -1,30 +1,26 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.NotesGlobalTimelineRequest import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class PublicTimelineRemoteMediator( private val accountKey: MicroBlogKey, private val service: MisskeyService, - database: CacheDatabase, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey = "public_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { is PagingRequest.Prepend -> return PagingResult( @@ -51,10 +47,7 @@ internal class PublicTimelineRemoteMediator( return PagingResult( endOfPaginationReached = response.isEmpty(), data = - response.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ), + response.render(accountKey), nextKey = response.lastOrNull()?.id, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/SearchStatusRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/SearchStatusRemoteMediator.kt index ab5f7e6c7..85686a7f3 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/SearchStatusRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/SearchStatusRemoteMediator.kt @@ -1,25 +1,21 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.NotesSearchRequest import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class SearchStatusRemoteMediator( private val service: MisskeyService, - database: CacheDatabase, private val accountKey: MicroBlogKey, private val query: String, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String = buildString { append("search_") @@ -27,10 +23,10 @@ internal class SearchStatusRemoteMediator( append(accountKey.toString()) } - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { is PagingRequest.Prepend -> { @@ -63,10 +59,7 @@ internal class SearchStatusRemoteMediator( return PagingResult( endOfPaginationReached = response.isEmpty(), data = - response.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ), + response.render(accountKey), nextKey = response.lastOrNull()?.id, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/SearchUserPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/SearchUserPagingSource.kt index e4bd78769..bd0c971da 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/SearchUserPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/SearchUserPagingSource.kt @@ -1,7 +1,8 @@ package dev.dimension.flare.data.datasource.misskey -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.UsersSearchRequest import dev.dimension.flare.model.MicroBlogKey @@ -12,28 +13,32 @@ internal class SearchUserPagingSource( private val service: MisskeyService, private val accountKey: MicroBlogKey, private val query: String, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): Int? = null - - override suspend fun doLoad(params: LoadParams): LoadResult { - service - .usersSearch( +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + val offset = + when (request) { + PagingRequest.Refresh -> 0 + is PagingRequest.Prepend -> { + return PagingResult( + endOfPaginationReached = true, + ) + } + is PagingRequest.Append -> request.nextKey.toIntOrNull() ?: 0 + } + val response = + service.usersSearch( UsersSearchRequest( query = query, - limit = params.loadSize, - offset = params.key ?: 0, + limit = pageSize, + offset = offset, ), - ).let { - return LoadResult.Page( - data = it.map { it.render(accountKey) }, - prevKey = null, - nextKey = - if (it.isEmpty()) { - null - } else { - (params.key ?: 0) + params.loadSize - }, - ) - } + ) + return PagingResult( + data = response.map { it.render(accountKey) }, + nextKey = if (response.isEmpty()) null else (offset + pageSize).toString(), + ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/StatusDetailRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/StatusDetailRemoteMediator.kt index 76105d9b9..f00e731f7 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/StatusDetailRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/StatusDetailRemoteMediator.kt @@ -1,32 +1,25 @@ package dev.dimension.flare.data.datasource.misskey -import SnowflakeIdGenerator import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.connect -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.IPinRequest import dev.dimension.flare.data.network.misskey.api.model.NotesChildrenRequest -import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey -import kotlinx.coroutines.flow.firstOrNull +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render +import org.koin.core.component.KoinComponent @OptIn(ExperimentalPagingApi::class) internal class StatusDetailRemoteMediator( private val statusKey: MicroBlogKey, - private val database: CacheDatabase, private val accountKey: MicroBlogKey, private val service: MisskeyService, private val statusOnly: Boolean, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader, + KoinComponent { override val pagingKey: String = buildString { append("status_detail_") @@ -38,10 +31,10 @@ internal class StatusDetailRemoteMediator( append(accountKey.toString()) } - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val result = when (request) { is PagingRequest.Append -> { @@ -66,29 +59,6 @@ internal class StatusDetailRemoteMediator( ) PagingRequest.Refresh -> { - if (!database.pagingTimelineDao().existsPaging(accountKey, pagingKey)) { - val status = - database - .statusDao() - .get(statusKey, AccountType.Specific(accountKey)) - .firstOrNull() - status?.let { - database.connect { - database - .pagingTimelineDao() - .insertAll( - listOf( - DbPagingTimeline( - accountType = AccountType.Specific(accountKey), - statusKey = statusKey, - pagingKey = pagingKey, - sortId = 0, - ), - ), - ) - } - } - } val current = service .notesShow( @@ -105,13 +75,7 @@ internal class StatusDetailRemoteMediator( return PagingResult( endOfPaginationReached = statusOnly || result.isEmpty(), data = - result.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - sortIdProvider = { - -SnowflakeIdGenerator.nextId() - }, - ), + result.render(accountKey), nextKey = if (request == PagingRequest.Refresh) { "" diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/TrendHashtagPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/TrendHashtagPagingSource.kt index d0812feb4..73fd00ce4 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/TrendHashtagPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/TrendHashtagPagingSource.kt @@ -1,30 +1,34 @@ package dev.dimension.flare.data.datasource.misskey -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.misskey.MisskeyService import dev.dimension.flare.ui.model.UiHashtag internal class TrendHashtagPagingSource( private val service: MisskeyService, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): Int? = null - - override suspend fun doLoad(params: LoadParams): LoadResult { - service - .hashtagsTrend() - .map { +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend || request is PagingRequest.Append) { + return PagingResult( + endOfPaginationReached = true, + ) + } + val data = + service.hashtagsTrend().map { UiHashtag( hashtag = it.tag, description = null, searchContent = "#${it.tag}", ) - }.let { - return LoadResult.Page( - data = it, - prevKey = null, - nextKey = null, - ) } + return PagingResult( + endOfPaginationReached = true, + data = data, + ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/TrendsUserPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/TrendsUserPagingSource.kt index e1cdbdf7e..10deabd2c 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/TrendsUserPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/TrendsUserPagingSource.kt @@ -1,7 +1,8 @@ package dev.dimension.flare.data.datasource.misskey -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.PinnedUsersRequest import dev.dimension.flare.model.MicroBlogKey @@ -11,20 +12,23 @@ import dev.dimension.flare.ui.model.mapper.render internal class TrendsUserPagingSource( private val service: MisskeyService, private val accountKey: MicroBlogKey, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): Int? = null - - override suspend fun doLoad(params: LoadParams): LoadResult { - service - .pinnedUsers(PinnedUsersRequest(limit = params.loadSize)) - .map { +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend || request is PagingRequest.Append) { + return PagingResult( + endOfPaginationReached = true, + ) + } + val data = + service.pinnedUsers(PinnedUsersRequest(limit = pageSize)).map { it.render(accountKey) - }.let { - return LoadResult.Page( - data = it, - prevKey = null, - nextKey = null, - ) } + return PagingResult( + endOfPaginationReached = true, + data = data, + ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/UserTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/UserTimelineRemoteMediator.kt index 5eccba686..2df5bc1a2 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/UserTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/UserTimelineRemoteMediator.kt @@ -1,30 +1,25 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.UsersNotesRequest import dev.dimension.flare.data.network.misskey.api.model.UsersShowRequest import dev.dimension.flare.model.MicroBlogKey -import kotlin.time.Instant +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class UserTimelineRemoteMediator( private val accountKey: MicroBlogKey, private val service: MisskeyService, private val userKey: MicroBlogKey, - database: CacheDatabase, private val onlyMedia: Boolean = false, private val withReplies: Boolean = false, private val withPinned: Boolean = false, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { var pinnedIds = emptyList() override val pagingKey: String @@ -44,10 +39,10 @@ internal class UserTimelineRemoteMediator( append(userKey.toString()) } - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { is PagingRequest.Prepend -> return PagingResult( @@ -123,20 +118,7 @@ internal class UserTimelineRemoteMediator( return PagingResult( endOfPaginationReached = response.isEmpty(), data = - response.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - sortIdProvider = { - if (it.id in pinnedIds) { - Long.MAX_VALUE - } else { - Instant.parse(it.createdAt).toEpochMilliseconds() - } - }, - pinnedProvider = { - it.id in pinnedIds - }, - ), + response.render(accountKey), nextKey = response.lastOrNull()?.id, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/pleroma/PleromaDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/pleroma/PleromaDataSource.kt index 1d6279ed4..f03507ad1 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/pleroma/PleromaDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/pleroma/PleromaDataSource.kt @@ -2,7 +2,6 @@ package dev.dimension.flare.data.datasource.pleroma import dev.dimension.flare.data.datasource.mastodon.MastodonDataSource import dev.dimension.flare.data.datasource.microblog.ReactionDataSource -import dev.dimension.flare.data.datasource.microblog.StatusEvent import dev.dimension.flare.model.MicroBlogKey internal class PleromaDataSource( @@ -12,12 +11,11 @@ internal class PleromaDataSource( accountKey = accountKey, instance = instance, ), - ReactionDataSource, - StatusEvent.Pleroma { - override fun react( - statusKey: MicroBlogKey, - hasReacted: Boolean, - reaction: String, - ) { - } + ReactionDataSource { +// override fun react( +// statusKey: MicroBlogKey, +// hasReacted: Boolean, +// reaction: String, +// ) { +// } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/rss/RssDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/rss/RssDataSource.kt index c21545017..7be3a7994 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/rss/RssDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/rss/RssDataSource.kt @@ -1,19 +1,16 @@ package dev.dimension.flare.data.datasource.rss import dev.dimension.flare.data.database.app.AppDatabase -import dev.dimension.flare.data.database.cache.CacheDatabase import org.koin.core.component.KoinComponent import org.koin.core.component.inject internal object RssDataSource : KoinComponent { - private val database: CacheDatabase by inject() private val appDatabase: AppDatabase by inject() fun fetchLoader(url: String) = RssTimelineRemoteMediator( url = url, - cacheDatabase = database, fetchSource = { appDatabase .rssSourceDao() diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/rss/RssTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/rss/RssTimelineRemoteMediator.kt index ad664fce1..db59679e9 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/rss/RssTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/rss/RssTimelineRemoteMediator.kt @@ -1,36 +1,21 @@ package dev.dimension.flare.data.datasource.rss -import androidx.paging.ExperimentalPagingApi import dev.dimension.flare.data.database.app.model.DbRssSources -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.createDbPagingTimelineWithStatus -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.database.cache.model.DbStatus -import dev.dimension.flare.data.database.cache.model.DbStatusWithUser -import dev.dimension.flare.data.database.cache.model.StatusContent -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.rss.RssService import dev.dimension.flare.data.network.rss.model.Feed -import dev.dimension.flare.model.AccountType -import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.mapper.fromRss -import dev.dimension.flare.ui.model.mapper.parseRssDateToInstant +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render import dev.dimension.flare.ui.model.mapper.title -import dev.dimension.flare.ui.render.parseHtml -import kotlin.time.Clock -@OptIn(ExperimentalPagingApi::class) internal class RssTimelineRemoteMediator( private val url: String, - private val cacheDatabase: CacheDatabase, private val fetchFeed: suspend (String) -> Feed = RssService::fetch, private val fetchIcon: suspend (String) -> String? = RssService::fetchIcon, private val fetchSource: suspend (String) -> DbRssSources?, -) : BaseTimelineRemoteMediator( - database = cacheDatabase, - ) { +) : CacheableRemoteLoader { override val pagingKey: String get() = buildString { @@ -38,10 +23,10 @@ internal class RssTimelineRemoteMediator( append(url) } - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val rssSource = fetchSource(url) val response = fetchFeed(url) val title = rssSource?.title ?: response.title @@ -49,117 +34,31 @@ internal class RssTimelineRemoteMediator( val content = when (response) { is Feed.Atom -> - response.entries - .map { - StatusContent.Rss.RssContent.Atom( - it, - source = title, - icon = icon, - openInBrowser = rssSource?.openInBrowser ?: false, - ) - }.map { - DbStatusWithUser( - user = null, - data = - DbStatus( - statusKey = - MicroBlogKey.fromRss( - it.data.links - .first() - .href, - ), - accountType = AccountType.Guest, - userKey = null, - content = StatusContent.Rss(it), - text = - it.data.content - ?.value - ?.let { html -> parseHtml(html) } - ?.wholeText(), - createdAt = - (it.data.published ?: it.data.updated) - ?.let { parseRssDateToInstant(it) } - ?: Clock.System.now(), - ), - ) - } + response.entries.map { + it.render( + sourceName = title, + sourceIcon = icon, + openInBrowser = rssSource?.openInBrowser ?: false, + ) + } is Feed.RDF -> - response.items - .map { - StatusContent.Rss.RssContent.RDF( - it, - source = title, - icon = icon, - openInBrowser = rssSource?.openInBrowser ?: false, - ) - }.map { - DbStatusWithUser( - user = null, - data = - DbStatus( - statusKey = - MicroBlogKey.fromRss( - it.data.link, - ), - accountType = AccountType.Guest, - userKey = null, - content = StatusContent.Rss(it), - text = - it.data.description - ?.let { html -> parseHtml(html) } - ?.wholeText(), - createdAt = - it.data.date?.let { parseRssDateToInstant(it) } - ?: Clock.System.now(), - ), - ) - } + response.items.map { + it.render( + sourceName = title, + sourceIcon = icon, + openInBrowser = rssSource?.openInBrowser ?: false, + ) + } is Feed.Rss20 -> - response.channel.items - .map { - StatusContent.Rss.RssContent.Rss20( - it, - source = title, - icon = icon, - openInBrowser = rssSource?.openInBrowser ?: false, - ) - }.map { - DbStatusWithUser( - user = null, - data = - DbStatus( - statusKey = - MicroBlogKey.fromRss( - it.data.link, - ), - accountType = AccountType.Guest, - userKey = null, - content = StatusContent.Rss(it), - text = - it.data.description - ?.let { html -> parseHtml(html) } - ?.wholeText(), - createdAt = - it.data.pubDate - ?.let { - parseRssDateToInstant( - it, - ) - } - ?: Clock.System.now(), - ), - ) - } - }.mapIndexed { index, status -> - createDbPagingTimelineWithStatus( - accountType = AccountType.Guest, - pagingKey = pagingKey, - sortId = status.data.createdAt.toEpochMilliseconds(), - status = status, - references = mapOf(), - ) + response.channel.items.map { + it.render( + sourceName = title, + sourceIcon = icon, + openInBrowser = rssSource?.openInBrowser ?: false, + ) + } } return PagingResult( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/CommentChildRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/CommentChildRemoteMediator.kt index 97c79b417..14dcdca9f 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/CommentChildRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/CommentChildRemoteMediator.kt @@ -1,33 +1,28 @@ package dev.dimension.flare.data.datasource.vvo -import SnowflakeIdGenerator import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.vvo.VVOService import dev.dimension.flare.data.repository.LoginExpiredException import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class CommentChildRemoteMediator( private val service: VVOService, private val commentKey: MicroBlogKey, private val accountKey: MicroBlogKey, - database: CacheDatabase, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String = "status_comments_child_${commentKey}_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val config = service.config() if (config.data?.login != true) { throw LoginExpiredException( @@ -35,13 +30,13 @@ internal class CommentChildRemoteMediator( platformType = PlatformType.VVo, ) } + val response = when (request) { PagingRequest.Refresh -> { - service - .getHotFlowChild( - cid = commentKey.id, - ) + service.getHotFlowChild( + cid = commentKey.id, + ) } is PagingRequest.Prepend -> { @@ -59,22 +54,9 @@ internal class CommentChildRemoteMediator( } val maxId = response.maxID?.takeIf { it != 0L } - val status = response.data.orEmpty() - - val data = - status.map { comment -> - comment.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - sortIdProvider = { - -SnowflakeIdGenerator.nextId() - }, - ) - } - return PagingResult( endOfPaginationReached = maxId == null, - data = data, + data = response.data.orEmpty().map { it.render(accountKey) }, nextKey = maxId?.toString(), ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/CommentPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/CommentPagingSource.kt index 458abacbf..7479e9ab2 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/CommentPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/CommentPagingSource.kt @@ -1,51 +1,56 @@ package dev.dimension.flare.data.datasource.vvo -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource -import dev.dimension.flare.data.datasource.microblog.StatusEvent +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.vvo.VVOService import dev.dimension.flare.data.repository.LoginExpiredException import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.model.mapper.render internal class CommentPagingSource( private val service: VVOService, - private val event: StatusEvent.VVO, private val accountKey: MicroBlogKey, private val onClearMarker: () -> Unit, -) : BasePagingSource() { - override suspend fun doLoad(params: LoadParams): LoadResult { +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { val config = service.config() if (config.data?.login != true) { - return LoadResult.Error( - LoginExpiredException( - accountKey = accountKey, - platformType = PlatformType.VVo, - ), + throw LoginExpiredException( + accountKey = accountKey, + platformType = PlatformType.VVo, ) } - if (params.key == null) { + val page = + when (request) { + PagingRequest.Refresh -> 1 + is PagingRequest.Prepend -> + return PagingResult( + endOfPaginationReached = true, + ) + is PagingRequest.Append -> request.nextKey.toIntOrNull() ?: 1 + } + if (request == PagingRequest.Refresh) { onClearMarker.invoke() } val response = service.getComments( - page = params.key ?: 1, + page = page, ) - val nextPage = params.key?.plus(1) ?: 2 val data = response.data.orEmpty().map { - it.render(accountKey, event) + it.render(accountKey) } - - return LoadResult.Page( + return PagingResult( data = data, - prevKey = null, - nextKey = nextPage.takeIf { it != params.key && data.any() }, + endOfPaginationReached = data.isEmpty(), + nextKey = (page + 1).takeIf { data.isNotEmpty() }?.toString(), ) } - - override fun getRefreshKey(state: PagingState): Int? = null } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/DiscoverStatusRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/DiscoverStatusRemoteMediator.kt index d232937de..9e1692b20 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/DiscoverStatusRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/DiscoverStatusRemoteMediator.kt @@ -1,33 +1,28 @@ package dev.dimension.flare.data.datasource.vvo -import SnowflakeIdGenerator import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.vvo.VVOService import dev.dimension.flare.data.repository.LoginExpiredException import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class DiscoverStatusRemoteMediator( private val service: VVOService, - database: CacheDatabase, private val accountKey: MicroBlogKey, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String = "discover_status_$accountKey" private val containerId = "102803" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val config = service.config() if (config.data?.login != true) { throw LoginExpiredException( @@ -39,25 +34,19 @@ internal class DiscoverStatusRemoteMediator( val page = when (request) { is PagingRequest.Append -> request.nextKey.toIntOrNull() ?: 0 - is PagingRequest.Prepend -> 0 - PagingRequest.Refresh -> 0 - } - - val response = - when (request) { - PagingRequest.Refresh -> { - service.getContainerIndex(containerId = containerId) - } - is PagingRequest.Prepend -> { return PagingResult( endOfPaginationReached = true, ) } + PagingRequest.Refresh -> 0 + } - is PagingRequest.Append -> { - service.getContainerIndex(containerId = containerId, sinceId = page.toString()) - } + val response = + if (request is PagingRequest.Append) { + service.getContainerIndex(containerId = containerId, sinceId = page.toString()) + } else { + service.getContainerIndex(containerId = containerId) } val status = @@ -66,20 +55,9 @@ internal class DiscoverStatusRemoteMediator( ?.mapNotNull { it.mblog } .orEmpty() - val data = - status.map { statusItem -> - statusItem.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - sortIdProvider = { - -SnowflakeIdGenerator.nextId() - }, - ) - } - return PagingResult( endOfPaginationReached = status.isEmpty(), - data = data, + data = status.map { it.render(accountKey) }, nextKey = (page + 1).toString(), ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/FansPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/FansPagingSource.kt index 02741a4a5..796284c3f 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/FansPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/FansPagingSource.kt @@ -1,7 +1,8 @@ package dev.dimension.flare.data.datasource.vvo -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.vvo.VVOService import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiProfile @@ -11,9 +12,7 @@ internal class FansPagingSource( private val service: VVOService, private val accountKey: MicroBlogKey, private val userKey: MicroBlogKey, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): Int? = null - +) : RemoteLoader { private val containerId by lazy { if (accountKey == userKey) { "231016_-_selffans" @@ -22,9 +21,20 @@ internal class FansPagingSource( } } - override suspend fun doLoad(params: LoadParams): LoadResult { - val nextPage = params.key ?: 0 - val limit = params.loadSize + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + val nextPage = + when (request) { + PagingRequest.Refresh -> 0 + is PagingRequest.Prepend -> { + return PagingResult( + endOfPaginationReached = true, + ) + } + is PagingRequest.Append -> request.nextKey.toIntOrNull() ?: 0 + } val users = service .getContainerIndex(containerId = containerId, sinceId = nextPage.toString()) @@ -38,10 +48,9 @@ internal class FansPagingSource( ?.map { it.render(accountKey = accountKey) }.orEmpty() - return LoadResult.Page( + return PagingResult( data = users, - prevKey = null, - nextKey = if (users.isEmpty()) null else users.size + nextPage, + nextKey = if (users.isEmpty()) null else (users.size + nextPage).toString(), ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/FavouriteRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/FavouriteRemoteMediator.kt index f3953ae16..d490f99c8 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/FavouriteRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/FavouriteRemoteMediator.kt @@ -1,31 +1,26 @@ package dev.dimension.flare.data.datasource.vvo -import SnowflakeIdGenerator -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.vvo.VVOService import dev.dimension.flare.data.repository.LoginExpiredException import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render internal class FavouriteRemoteMediator( private val service: VVOService, - database: CacheDatabase, private val accountKey: MicroBlogKey, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String = "favourite_$accountKey" private val containerId = "230259" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val config = service.config() if (config.data?.login != true) { throw LoginExpiredException( @@ -36,8 +31,13 @@ internal class FavouriteRemoteMediator( val page = when (request) { + PagingRequest.Refresh -> null + is PagingRequest.Prepend -> { + return PagingResult( + endOfPaginationReached = true, + ) + } is PagingRequest.Append -> request.nextKey.toIntOrNull() - else -> null } val response = @@ -68,21 +68,10 @@ internal class FavouriteRemoteMediator( ?.filter { it.user?.id != null } .orEmpty() - val data = - status.map { statusItem -> - statusItem.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - sortIdProvider = { - -SnowflakeIdGenerator.nextId() - }, - ) - } val nextKey = response.data?.cardlistInfo?.page - return PagingResult( endOfPaginationReached = nextKey == null, - data = data, + data = status.map { it.render(accountKey) }, nextKey = nextKey?.toString(), ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/FollowingPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/FollowingPagingSource.kt index e07644db7..fb32b1770 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/FollowingPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/FollowingPagingSource.kt @@ -1,7 +1,8 @@ package dev.dimension.flare.data.datasource.vvo -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.vvo.VVOService import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiProfile @@ -11,9 +12,7 @@ internal class FollowingPagingSource( private val service: VVOService, private val accountKey: MicroBlogKey, private val userKey: MicroBlogKey, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): Int? = null - +) : RemoteLoader { private val containerId by lazy { if (accountKey == userKey) { "231093_-_selffollowed" @@ -22,9 +21,20 @@ internal class FollowingPagingSource( } } - override suspend fun doLoad(params: LoadParams): LoadResult { - val nextPage = params.key ?: 1 - val limit = params.loadSize + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + val nextPage = + when (request) { + PagingRequest.Refresh -> 1 + is PagingRequest.Prepend -> { + return PagingResult( + endOfPaginationReached = true, + ) + } + is PagingRequest.Append -> request.nextKey.toIntOrNull() ?: 1 + } val users = service .getContainerIndex(containerId = containerId, page = nextPage) @@ -38,10 +48,9 @@ internal class FollowingPagingSource( ?.map { it.render(accountKey = accountKey) }.orEmpty() - return LoadResult.Page( + return PagingResult( data = users, - prevKey = null, - nextKey = if (users.isEmpty()) null else nextPage + 1, + nextKey = if (users.isEmpty()) null else (nextPage + 1).toString(), ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/HomeTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/HomeTimelineRemoteMediator.kt index b4d54c12a..125672ad0 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/HomeTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/HomeTimelineRemoteMediator.kt @@ -3,34 +3,28 @@ package dev.dimension.flare.data.datasource.vvo import androidx.paging.ExperimentalPagingApi import dev.dimension.flare.common.InAppNotification import dev.dimension.flare.common.Message -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.vvo.VVOService import dev.dimension.flare.data.repository.LoginExpiredException import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class HomeTimelineRemoteMediator( private val service: VVOService, - database: CacheDatabase, private val accountKey: MicroBlogKey, private val inAppNotification: InAppNotification, -) : BaseTimelineRemoteMediator( - database = database, - ) { - override val pagingKey = "home_$accountKey" +) : CacheableRemoteLoader { + override val pagingKey: String = "home_$accountKey" - override suspend fun initialize(): InitializeAction = InitializeAction.SKIP_INITIAL_REFRESH - - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val config = service.config() if (config.data?.login != true) { inAppNotification.onError( @@ -48,9 +42,7 @@ internal class HomeTimelineRemoteMediator( val response = when (request) { - PagingRequest.Refresh -> { - service.getFriendsTimeline() - } + PagingRequest.Refresh -> service.getFriendsTimeline() is PagingRequest.Prepend -> { return PagingResult( @@ -65,18 +57,13 @@ internal class HomeTimelineRemoteMediator( } } - val statuses = response.data?.statuses.orEmpty() - val data = - statuses.map { status -> - status.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ) - } - return PagingResult( endOfPaginationReached = response.data?.nextCursorStr == null, - data = data, + data = + response.data + ?.statuses + .orEmpty() + .map { it.render(accountKey) }, nextKey = response.data?.nextCursorStr, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/LikePagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/LikePagingSource.kt index da2cb347c..af121c3d2 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/LikePagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/LikePagingSource.kt @@ -1,51 +1,55 @@ package dev.dimension.flare.data.datasource.vvo -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource -import dev.dimension.flare.data.datasource.microblog.StatusEvent +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.vvo.VVOService import dev.dimension.flare.data.repository.LoginExpiredException import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.model.mapper.render internal class LikePagingSource( private val service: VVOService, - private val event: StatusEvent.VVO, private val accountKey: MicroBlogKey, private val onClearMarker: () -> Unit, -) : BasePagingSource() { - override suspend fun doLoad(params: LoadParams): LoadResult { +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { val config = service.config() if (config.data?.login != true) { - return LoadResult.Error( - LoginExpiredException( - accountKey = accountKey, - platformType = PlatformType.VVo, - ), + throw LoginExpiredException( + accountKey = accountKey, + platformType = PlatformType.VVo, ) } - if (params.key == null) { + + val page = + when (request) { + PagingRequest.Refresh -> 1 + is PagingRequest.Prepend -> { + return PagingResult( + endOfPaginationReached = true, + ) + } + is PagingRequest.Append -> request.nextKey.toIntOrNull() ?: 1 + } + if (request == PagingRequest.Refresh) { onClearMarker.invoke() } - val response = - service.getAttitudes( - page = params.key ?: 1, - ) - val nextPage = params.key?.plus(1) ?: 2 + val response = service.getAttitudes(page = page) val data = - response.data.orEmpty().filter { it.idStr != null }.map { - it.render(accountKey, event) - } - - return LoadResult.Page( + response.data + .orEmpty() + .filter { it.idStr != null } + .map { it.render(accountKey) } + return PagingResult( data = data, - prevKey = null, - nextKey = nextPage.takeIf { it != params.key && data.any() }, + nextKey = if (data.isEmpty()) null else (page + 1).toString(), ) } - - override fun getRefreshKey(state: PagingState): Int? = null } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/LikeRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/LikeRemoteMediator.kt index d61610edc..89bf30172 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/LikeRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/LikeRemoteMediator.kt @@ -1,31 +1,26 @@ package dev.dimension.flare.data.datasource.vvo -import SnowflakeIdGenerator -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.vvo.VVOService import dev.dimension.flare.data.repository.LoginExpiredException import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render internal class LikeRemoteMediator( private val service: VVOService, - database: CacheDatabase, private val accountKey: MicroBlogKey, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String = "like_$accountKey" private val containerId = "2308691748186704_-_mix" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val config = service.config() if (config.data?.login != true) { throw LoginExpiredException( @@ -36,8 +31,13 @@ internal class LikeRemoteMediator( val page = when (request) { + PagingRequest.Refresh -> null + is PagingRequest.Prepend -> { + return PagingResult( + endOfPaginationReached = true, + ) + } is PagingRequest.Append -> request.nextKey.toIntOrNull() - else -> null } val response = @@ -75,21 +75,10 @@ internal class LikeRemoteMediator( }?.filter { it.user?.id != null } .orEmpty() - val data = - status.map { statusItem -> - statusItem.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - sortIdProvider = { - -SnowflakeIdGenerator.nextId() - }, - ) - } val nextKey = response.data?.cardlistInfo?.page - return PagingResult( endOfPaginationReached = nextKey == null, - data = data, + data = status.map { it.render(accountKey) }, nextKey = nextKey?.toString(), ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/MentionRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/MentionRemoteMediator.kt index 452203a9e..4085a36df 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/MentionRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/MentionRemoteMediator.kt @@ -1,32 +1,28 @@ package dev.dimension.flare.data.datasource.vvo import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.vvo.VVOService import dev.dimension.flare.data.repository.LoginExpiredException import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class MentionRemoteMediator( private val service: VVOService, - database: CacheDatabase, private val accountKey: MicroBlogKey, private val onClearMarker: () -> Unit, -) : BaseTimelineRemoteMediator( - database = database, - ) { - override val pagingKey = "mention_$accountKey" +) : CacheableRemoteLoader { + override val pagingKey: String = "mention_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val config = service.config() if (config.data?.login != true) { throw LoginExpiredException( @@ -38,48 +34,25 @@ internal class MentionRemoteMediator( val page = when (request) { PagingRequest.Refresh -> 0 - is PagingRequest.Prepend -> return PagingResult( - endOfPaginationReached = true, - ) - is PagingRequest.Append -> request.nextKey.toIntOrNull() ?: 0 - } - - val response = - when (request) { - PagingRequest.Refresh -> { - val result = - service - .getMentionsAt( - page = page, - ) - onClearMarker.invoke() - result - } - is PagingRequest.Prepend -> { return PagingResult( endOfPaginationReached = true, ) } - - is PagingRequest.Append -> { - service.getMentionsAt( - page = page, - ) - } + is PagingRequest.Append -> request.nextKey.toIntOrNull() ?: 0 } - val statuses = response.data.orEmpty() - val data = - statuses.map { status -> - status.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - ) - } + val response = + service.getMentionsAt( + page = page, + ) + if (request == PagingRequest.Refresh) { + onClearMarker.invoke() + } + val data = response.data.orEmpty().map { it.render(accountKey) } return PagingResult( - endOfPaginationReached = response.data.isNullOrEmpty(), + endOfPaginationReached = data.isEmpty(), data = data, nextKey = (page + 1).toString(), ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/SearchStatusRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/SearchStatusRemoteMediator.kt index c272dadbb..6d5a06374 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/SearchStatusRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/SearchStatusRemoteMediator.kt @@ -1,26 +1,22 @@ package dev.dimension.flare.data.datasource.vvo import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.vvo.VVOService import dev.dimension.flare.data.repository.LoginExpiredException import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class SearchStatusRemoteMediator( private val service: VVOService, - database: CacheDatabase, private val accountKey: MicroBlogKey, private val query: String, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String = buildString { append("search_") @@ -32,10 +28,10 @@ internal class SearchStatusRemoteMediator( "100103type=1&q=$query&t=" } - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val config = service.config() if (config.data?.login != true) { throw LoginExpiredException( @@ -47,56 +43,30 @@ internal class SearchStatusRemoteMediator( val page = when (request) { is PagingRequest.Append -> request.nextKey.toIntOrNull() ?: 1 - is PagingRequest.Prepend -> 1 - PagingRequest.Refresh -> 1 - } - - val response = - when (request) { - PagingRequest.Refresh -> { - service - .getContainerIndex( - containerId = containerId, - pageType = "searchall", - ) - } - is PagingRequest.Prepend -> { return PagingResult( endOfPaginationReached = true, ) } - - is PagingRequest.Append -> { - service.getContainerIndex( - containerId = containerId, - pageType = "searchall", - page = page, - ) - } + PagingRequest.Refresh -> 1 } + val response = + service.getContainerIndex( + containerId = containerId, + pageType = "searchall", + page = page.takeIf { request is PagingRequest.Append }, + ) + val status = response.data ?.cards ?.flatMap { card -> listOfNotNull(card.mblog) + card.cardGroup?.mapNotNull { it.mblog }.orEmpty() } .orEmpty() - val data = - status.map { statusItem -> - statusItem.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - sortIdProvider = { item -> - val index = status.indexOf(item) - -(index + page * pageSize).toLong() - }, - ) - } - return PagingResult( endOfPaginationReached = status.isEmpty(), - data = data, + data = status.map { it.render(accountKey) }, nextKey = (page + 1).toString(), ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/SearchUserPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/SearchUserPagingSource.kt index 542857b23..99c425ffc 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/SearchUserPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/SearchUserPagingSource.kt @@ -1,7 +1,8 @@ package dev.dimension.flare.data.datasource.vvo -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.vvo.VVOService import dev.dimension.flare.data.repository.LoginExpiredException import dev.dimension.flare.model.MicroBlogKey @@ -13,28 +14,39 @@ internal class SearchUserPagingSource( private val service: VVOService, private val accountKey: MicroBlogKey, private val query: String, -) : BasePagingSource() { +) : RemoteLoader { private val containerId by lazy { "100103type=3&q=$query&t=" } - override fun getRefreshKey(state: PagingState): Int? = null - - override suspend fun doLoad(params: LoadParams): LoadResult { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { val config = service.config() if (config.data?.login != true) { - return LoadResult.Error( - LoginExpiredException( - accountKey = accountKey, - platformType = PlatformType.VVo, - ), + throw LoginExpiredException( + accountKey = accountKey, + platformType = PlatformType.VVo, ) } + + val page = + when (request) { + PagingRequest.Refresh -> null + is PagingRequest.Prepend -> { + return PagingResult( + endOfPaginationReached = true, + ) + } + is PagingRequest.Append -> request.nextKey.toIntOrNull() + } + val response = service.getContainerIndex( containerId = containerId, pageType = "searchall", - page = params.key, + page = page, ) val users = response.data @@ -44,10 +56,10 @@ internal class SearchUserPagingSource( }?.mapNotNull { it.user }.orEmpty() - return LoadResult.Page( + + return PagingResult( data = users.map { it.render(accountKey = accountKey) }, - prevKey = null, - nextKey = if (users.isEmpty()) null else params.key?.plus(1), + nextKey = if (users.isEmpty()) null else ((page ?: 0) + 1).toString(), ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/StatusCommentRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/StatusCommentRemoteMediator.kt index 7add6deaa..249b1c8f6 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/StatusCommentRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/StatusCommentRemoteMediator.kt @@ -1,33 +1,28 @@ package dev.dimension.flare.data.datasource.vvo -import SnowflakeIdGenerator import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.vvo.VVOService import dev.dimension.flare.data.repository.LoginExpiredException import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class StatusCommentRemoteMediator( - database: CacheDatabase, private val service: VVOService, private val statusKey: MicroBlogKey, private val accountKey: MicroBlogKey, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String = "status_comments_${statusKey}_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val config = service.config() if (config.data?.login != true) { throw LoginExpiredException( @@ -39,12 +34,11 @@ internal class StatusCommentRemoteMediator( val response = when (request) { PagingRequest.Refresh -> { - service - .getHotComments( - id = statusKey.id, - mid = statusKey.id, - maxId = null, - ) + service.getHotComments( + id = statusKey.id, + mid = statusKey.id, + maxId = null, + ) } is PagingRequest.Prepend -> { @@ -63,22 +57,13 @@ internal class StatusCommentRemoteMediator( } val maxId = response.data?.maxID?.takeIf { it != 0L } - val comments = response.data?.data.orEmpty() - - val data = - comments.map { comment -> - comment.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - sortIdProvider = { item -> - -SnowflakeIdGenerator.nextId() - }, - ) - } - return PagingResult( endOfPaginationReached = maxId == null, - data = data, + data = + response.data + ?.data + .orEmpty() + .map { it.render(accountKey) }, nextKey = maxId?.toString(), ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/StatusRepostRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/StatusRepostRemoteMediator.kt index 95ea72b06..1f064f5e8 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/StatusRepostRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/StatusRepostRemoteMediator.kt @@ -1,32 +1,28 @@ package dev.dimension.flare.data.datasource.vvo import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.vvo.VVOService import dev.dimension.flare.data.repository.LoginExpiredException import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class StatusRepostRemoteMediator( private val service: VVOService, private val statusKey: MicroBlogKey, private val accountKey: MicroBlogKey, - private val database: CacheDatabase, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String = "status_reposts_${statusKey}_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val config = service.config() if (config.data?.login != true) { throw LoginExpiredException( @@ -39,53 +35,23 @@ internal class StatusRepostRemoteMediator( when (request) { PagingRequest.Refresh -> 1 is PagingRequest.Append -> request.nextKey.toIntOrNull() ?: 1 - is PagingRequest.Prepend -> return PagingResult(endOfPaginationReached = true) - } - - val response = - when (request) { - PagingRequest.Refresh -> { - service - .getRepostTimeline( - id = statusKey.id, - page = page, - ) - } - is PagingRequest.Prepend -> { return PagingResult( endOfPaginationReached = true, ) } - - is PagingRequest.Append -> { - service.getRepostTimeline( - id = statusKey.id, - page = page, - ) - } } - val statuses = - response.data - ?.data - .orEmpty() - - val data = - statuses.map { status -> - status.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - sortIdProvider = { item -> - val index = statuses.indexOf(item) - -(index + page * pageSize).toLong() - }, - ) - } + val response = + service.getRepostTimeline( + id = statusKey.id, + page = page, + ) + val statuses = response.data?.data.orEmpty() return PagingResult( endOfPaginationReached = statuses.isEmpty(), - data = data, + data = statuses.map { it.render(accountKey) }, nextKey = (page + 1).toString(), ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/TrendHashtagPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/TrendHashtagPagingSource.kt index 28949dcdc..cfdc68fab 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/TrendHashtagPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/TrendHashtagPagingSource.kt @@ -1,7 +1,8 @@ package dev.dimension.flare.data.datasource.vvo -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.vvo.VVOService import dev.dimension.flare.data.repository.LoginExpiredException import dev.dimension.flare.model.MicroBlogKey @@ -11,44 +12,49 @@ import dev.dimension.flare.ui.model.UiHashtag internal class TrendHashtagPagingSource( private val accountKey: MicroBlogKey, private val service: VVOService, -) : BasePagingSource() { +) : RemoteLoader { private val containerId = "106003type=25&filter_type=realtimehot" - override fun getRefreshKey(state: PagingState): Int? = null + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend || request is PagingRequest.Append) { + return PagingResult( + endOfPaginationReached = true, + ) + } - override suspend fun doLoad(params: LoadParams): LoadResult { val config = service.config() if (config.data?.login != true) { - return LoadResult.Error( - LoginExpiredException( - accountKey = accountKey, - platformType = PlatformType.VVo, - ), + throw LoginExpiredException( + accountKey = accountKey, + platformType = PlatformType.VVo, ) } - service - .getContainerIndex(containerId = containerId) - .data - ?.cards - ?.flatMap { - it.cardGroup.orEmpty() - }?.mapNotNull { - it.desc - }?.map { - UiHashtag( - hashtag = it, - description = null, - searchContent = "#$it#", - ) - }?.toList() - ?.take(10) - .orEmpty() - .let { - return LoadResult.Page( - data = it, - prevKey = null, - nextKey = null, - ) - } + + val data = + service + .getContainerIndex(containerId = containerId) + .data + ?.cards + ?.flatMap { + it.cardGroup.orEmpty() + }?.mapNotNull { + it.desc + }?.map { + UiHashtag( + hashtag = it, + description = null, + searchContent = "#$it#", + ) + }?.toList() + ?.take(10) + .orEmpty() + + return PagingResult( + data = data, + endOfPaginationReached = true, + ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/UserTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/UserTimelineRemoteMediator.kt index 212370de3..c212cf9e5 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/UserTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/UserTimelineRemoteMediator.kt @@ -1,11 +1,7 @@ package dev.dimension.flare.data.datasource.vvo -import SnowflakeIdGenerator import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.vvo.VVOService @@ -13,18 +9,17 @@ import dev.dimension.flare.data.repository.LoginExpiredException import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType import dev.dimension.flare.model.vvo +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class UserTimelineRemoteMediator( private val userKey: MicroBlogKey, private val service: VVOService, - private val database: CacheDatabase, private val accountKey: MicroBlogKey, private val mediaOnly: Boolean, -) : BaseTimelineRemoteMediator( - database = database, - ) { - override val pagingKey = +) : CacheableRemoteLoader { + override val pagingKey: String = buildString { append("user_timeline") if (mediaOnly) { @@ -33,14 +28,14 @@ internal class UserTimelineRemoteMediator( append(accountKey.toString()) append(userKey.toString()) } + private var containerid: String? = null - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { if (mediaOnly) { - // Not supported yet return PagingResult( endOfPaginationReached = true, ) @@ -53,6 +48,7 @@ internal class UserTimelineRemoteMediator( platformType = PlatformType.VVo, ) } + if (containerid == null) { containerid = service @@ -64,15 +60,15 @@ internal class UserTimelineRemoteMediator( it.tabType == vvo }?.containerid } + val response = when (request) { PagingRequest.Refresh -> { - service - .getContainerIndex( - type = "uid", - value = userKey.id, - containerId = containerid, - ) + service.getContainerIndex( + type = "uid", + value = userKey.id, + containerId = containerid, + ) } is PagingRequest.Prepend -> { @@ -90,22 +86,13 @@ internal class UserTimelineRemoteMediator( ) } } - val status = + + val data = response.data ?.cards ?.mapNotNull { it.mblog } .orEmpty() - - val data = - status.map { statusItem -> - statusItem.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - sortIdProvider = { - -SnowflakeIdGenerator.nextId() - }, - ) - } + .map { it.render(accountKey) } return PagingResult( endOfPaginationReached = response.data?.cardlistInfo?.sinceID == null, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/VVODataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/VVODataSource.kt index 30830af66..8a7f28078 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/VVODataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/VVODataSource.kt @@ -1,83 +1,67 @@ package dev.dimension.flare.data.datasource.vvo import androidx.paging.ExperimentalPagingApi -import androidx.paging.Pager -import androidx.paging.PagingData -import androidx.paging.cachedIn import dev.dimension.flare.common.CacheData -import dev.dimension.flare.common.Cacheable import dev.dimension.flare.common.FileItem import dev.dimension.flare.common.FileType import dev.dimension.flare.common.InAppNotification import dev.dimension.flare.common.MemCacheable import dev.dimension.flare.common.decodeJson -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.connect -import dev.dimension.flare.data.database.cache.mapper.VVO -import dev.dimension.flare.data.database.cache.mapper.toDbUser -import dev.dimension.flare.data.database.cache.model.DbEmoji -import dev.dimension.flare.data.database.cache.model.EmojiContent -import dev.dimension.flare.data.database.cache.model.StatusContent -import dev.dimension.flare.data.database.cache.model.updateStatusUseCase import dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataSource import dev.dimension.flare.data.datasource.microblog.ComposeConfig import dev.dimension.flare.data.datasource.microblog.ComposeData import dev.dimension.flare.data.datasource.microblog.ComposeProgress import dev.dimension.flare.data.datasource.microblog.ComposeType +import dev.dimension.flare.data.datasource.microblog.DatabaseUpdater import dev.dimension.flare.data.datasource.microblog.NotificationFilter -import dev.dimension.flare.data.datasource.microblog.ProfileAction +import dev.dimension.flare.data.datasource.microblog.PostEvent import dev.dimension.flare.data.datasource.microblog.ProfileTab -import dev.dimension.flare.data.datasource.microblog.StatusEvent -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineLoader -import dev.dimension.flare.data.datasource.microblog.pagingConfig -import dev.dimension.flare.data.datasource.microblog.relationKeyWithUserKey -import dev.dimension.flare.data.datasource.microblog.timelinePager +import dev.dimension.flare.data.datasource.microblog.datasource.NotificationDataSource +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.UserDataSource +import dev.dimension.flare.data.datasource.microblog.handler.EmojiHandler +import dev.dimension.flare.data.datasource.microblog.handler.NotificationHandler +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.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.vvo.VVOService import dev.dimension.flare.data.network.vvo.model.StatusDetailItem import dev.dimension.flare.data.repository.AccountRepository -import dev.dimension.flare.data.repository.LocalFilterRepository import dev.dimension.flare.data.repository.LoginExpiredException -import dev.dimension.flare.data.repository.tryRun import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType import dev.dimension.flare.shared.image.ImageCompressor import dev.dimension.flare.ui.model.UiAccount -import dev.dimension.flare.ui.model.UiEmoji import dev.dimension.flare.ui.model.UiHashtag import dev.dimension.flare.ui.model.UiProfile -import dev.dimension.flare.ui.model.UiRelation import dev.dimension.flare.ui.model.UiState -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.model.mapper.render -import dev.dimension.flare.ui.model.mapper.toUi import dev.dimension.flare.ui.model.toUi import dev.dimension.flare.ui.presenter.compose.ComposeStatus import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import kotlinx.collections.immutable.toImmutableMap -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import kotlin.time.Clock @OptIn(ExperimentalPagingApi::class) internal class VVODataSource( override val accountKey: MicroBlogKey, ) : AuthenticatedMicroblogDataSource, KoinComponent, - StatusEvent.VVO { - private val database: CacheDatabase by inject() - private val localFilterRepository: LocalFilterRepository by inject() - private val coroutineScope: CoroutineScope by inject() + NotificationDataSource, + UserDataSource, + RelationDataSource, + PostDataSource, + PostEventHandler.Handler { private val accountRepository: AccountRepository by inject() private val inAppNotification: InAppNotification by inject() private val imageCompressor: ImageCompressor by inject() @@ -90,66 +74,107 @@ internal class VVODataSource( ) } + private val loader by lazy { + VVOLoader( + accountKey = accountKey, + service = service, + ) + } + + private val emojiHandler by lazy { + EmojiHandler( + host = accountKey.host, + loader = loader, + ) + } + + override val notificationHandler by lazy { + NotificationHandler( + accountKey = accountKey, + loader = loader, + ) + } + + 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 relationHandler by lazy { + RelationHandler( + dataSource = loader, + accountType = AccountType.Specific(accountKey), + ) + } + + override val supportedRelationTypes: Set + get() = loader.supportedTypes + + override val postEventHandler by lazy { + PostEventHandler( + accountType = AccountType.Specific(accountKey), + handler = this, + ) + } + + override suspend fun handle( + event: PostEvent, + updater: DatabaseUpdater, + ) { + require(event is PostEvent.VVO) + when (event) { + is PostEvent.VVO.Favorite -> favorite(event) + is PostEvent.VVO.Like -> like(event) + is PostEvent.VVO.LikeComment -> likeComment(event) + } + } + override fun homeTimeline() = HomeTimelineRemoteMediator( - service, - database, - accountKey, - inAppNotification, + service = service, + accountKey = accountKey, + inAppNotification = inAppNotification, ) - override fun notification( - type: NotificationFilter, - pageSize: Int, - scope: CoroutineScope, - ): Flow> = + override fun notification(type: NotificationFilter): RemoteLoader = when (type) { - NotificationFilter.All -> TODO() - NotificationFilter.Mention -> - timelinePager( - pageSize = pageSize, - database = database, - scope = scope, - filterFlow = localFilterRepository.getFlow(forTimeline = true), - accountRepository = accountRepository, - mediator = - MentionRemoteMediator( - service, - database, - accountKey, - onClearMarker = { - MemCacheable.update(notificationMarkerMentionKey, 0) - }, - ), + NotificationFilter.All, + NotificationFilter.Mention, + -> + MentionRemoteMediator( + service = service, + accountKey = accountKey, + onClearMarker = { + notificationHandler.clear() + }, ) NotificationFilter.Comment -> - Pager( - config = pagingConfig, - ) { - CommentPagingSource( - service = service, - accountKey = accountKey, - event = this, - onClearMarker = { - MemCacheable.update(notificationMarkerCommentKey, 0) - }, - ) - }.flow.cachedIn(scope) + CommentPagingSource( + service = service, + accountKey = accountKey, + onClearMarker = { + notificationHandler.clear() + }, + ) NotificationFilter.Like -> - Pager( - config = pagingConfig, - ) { - LikePagingSource( - service = service, - accountKey = accountKey, - event = this, - onClearMarker = { - MemCacheable.update(notificationMarkerLikeKey, 0) - }, - ) - }.flow.cachedIn(scope) + LikePagingSource( + service = service, + accountKey = accountKey, + onClearMarker = { + notificationHandler.clear() + }, + ) } override val supportedNotificationFilter: List @@ -160,210 +185,118 @@ internal class VVODataSource( NotificationFilter.Like, ) - override fun userByAcct(acct: String): CacheData { - val (name, host) = MicroBlogKey.valueOf(acct.removePrefix("@")) - return Cacheable( - fetchSource = { - val config = service.config() - val uid = service.getUid(name) - requireNotNull(uid) { "user not found" } - val st = config.data?.st - requireNotNull(st) { "st is null" } - val profile = service.profileInfo(uid, st) - val user = profile.data?.user?.toDbUser() - requireNotNull(user) { "user not found" } - database.userDao().insert(user) - }, - cacheSource = { - database - .userDao() - .findByHandleAndHost(name, host, PlatformType.VVo) - .distinctUntilChanged() - .mapNotNull { it?.render(accountKey) } - }, - ) - } - - override fun userById(id: String): CacheData { - val userKey = MicroBlogKey(id, accountKey.host) - return Cacheable( - fetchSource = { - val config = service.config() - if (config.data?.login != true) { - throw LoginExpiredException( - accountKey = accountKey, - platformType = PlatformType.VVo, - ) - } - val st = config.data.st - requireNotNull(st) { "st is null" } - val profile = service.profileInfo(id, st) - val user = profile.data?.user?.toDbUser() - requireNotNull(user) { "user not found" } - database.userDao().insert(user) - }, - cacheSource = { - database - .userDao() - .findByKey(userKey) - .distinctUntilChanged() - .mapNotNull { it?.render(accountKey) } - }, - ) - } - - override fun relation(userKey: MicroBlogKey): Flow> = - MemCacheable( - relationKeyWithUserKey(userKey), - ) { - val config = service.config() - if (config.data?.login != true) { - throw LoginExpiredException( - accountKey = accountKey, - platformType = PlatformType.VVo, - ) - } - val st = config.data.st - requireNotNull(st) { "st is null" } - val profile = service.profileInfo(userKey.id, st) - val user = - profile.data - ?.user - requireNotNull(user) { "user not found" } - UiRelation( - following = user.following, - isFans = user.followMe ?: false, - ) - }.toUi() - override fun userTimeline( userKey: MicroBlogKey, mediaOnly: Boolean, ) = UserTimelineRemoteMediator( userKey = userKey, service = service, - database = database, accountKey = accountKey, mediaOnly = mediaOnly, ) - override fun context(statusKey: MicroBlogKey): BaseTimelineLoader { - TODO("Not yet implemented") - } + override fun context(statusKey: MicroBlogKey): RemoteLoader = + object : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + when (request) { + is PagingRequest.Prepend, + is PagingRequest.Append, + -> + return PagingResult( + endOfPaginationReached = true, + ) - override fun status(statusKey: MicroBlogKey): CacheData { - val pagingKey = "status_only_$statusKey" - val regex = - "\\\$render_data\\s*=\\s*(\\[\\{.*?\\}\\])\\[0\\]\\s*\\|\\|\\s*\\{\\};" - .toRegex() - return Cacheable( - fetchSource = { - val response = - service - .getStatusDetail(statusKey.id) - .split("\n") - .joinToString("") - val json = - regex - .find(response) - ?.groupValues - ?.get(1) - ?.decodeJson>() - ?: throw Exception("status not found") - val item = json.firstOrNull()?.status - - if (item != null) { - VVO.saveStatus( - accountKey = accountKey, - pagingKey = pagingKey, - database = database, - statuses = listOf(item), - ) - } else { - throw Exception("status not found") + PagingRequest.Refresh -> Unit } - }, - cacheSource = { - database - .statusDao() - .get(statusKey, accountType = AccountType.Specific(accountKey)) - .distinctUntilChanged() - .mapNotNull { it?.content?.render(this) } - }, - ) - } - fun comment(statusKey: MicroBlogKey): CacheData { - val pagingKey = "comment_only_$statusKey" - return Cacheable( - fetchSource = { - val item = + val status = loadStatusDetail(statusKey) + val comments = service - .getHotFlowChild(statusKey.id) - .rootComment - ?.firstOrNull() - if (item != null) { - // prevent overwrite status with empty quote -// VVO.saveComment( -// accountKey = accountKey, -// pagingKey = pagingKey, -// database = database, -// statuses = listOf(item), -// ) - } else { - throw Exception("status not found") - } - }, - cacheSource = { - database - .statusDao() - .get(statusKey, accountType = AccountType.Specific(accountKey)) - .distinctUntilChanged() - .mapNotNull { it?.content?.render(event = this) } - }, + .getHotComments( + id = statusKey.id, + mid = statusKey.id, + maxId = null, + ).data + ?.data + .orEmpty() + .map { it.render(accountKey) } + + return PagingResult( + endOfPaginationReached = true, + data = listOf(status) + comments, + ) + } + } + + override fun searchStatus(query: String) = + SearchStatusRemoteMediator( + service = service, + accountKey = accountKey, + query = query, ) - } - private suspend fun uploadMedia( - fileItem: FileItem, - st: String, - ): String { - val bytes = fileItem.readBytes() - val isImage = fileItem.type == FileType.Image + override fun searchUser(query: String): RemoteLoader = + SearchUserPagingSource( + service = service, + accountKey = accountKey, + query = query, + ) - val finalBytes = - if (isImage) { - imageCompressor.compress( - imageBytes = bytes, - maxSize = 5 * 1024 * 1024, - maxDimensions = 4096 to 4096, + override fun discoverUsers(): RemoteLoader = + object : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult = + PagingResult( + endOfPaginationReached = true, ) - } else { - bytes - } - val response = - service.uploadPic( - st = st, - bytes = finalBytes, - filename = fileItem.name ?: "file", - ) - return response.picID ?: throw Exception("upload failed") - } + } + + override fun discoverStatuses() = + DiscoverStatusRemoteMediator( + service = service, + accountKey = accountKey, + ) + + override fun discoverHashtags(): RemoteLoader = + TrendHashtagPagingSource( + accountKey = accountKey, + service = service, + ) + + override fun following(userKey: MicroBlogKey): RemoteLoader = + FollowingPagingSource( + service = service, + userKey = userKey, + accountKey = accountKey, + ) + + override fun fans(userKey: MicroBlogKey): RemoteLoader = + FansPagingSource( + service = service, + userKey = userKey, + accountKey = accountKey, + ) + + override fun profileTabs(userKey: MicroBlogKey): ImmutableList = + persistentListOf( + ProfileTab.Timeline( + type = ProfileTab.Timeline.Type.Status, + loader = userTimeline(userKey, false), + ), + ) override suspend fun compose( data: ComposeData, progress: (ComposeProgress) -> Unit, ) { val maxProgress = data.medias.size + 1 - val config = service.config() - if (config.data?.login != true) { - throw LoginExpiredException( - accountKey = accountKey, - platformType = PlatformType.VVo, - ) - } - val st = config.data.st - requireNotNull(st) { "st is null" } + val st = ensureLogin() + val mediaIds = data.medias.mapIndexed { index, (it, _) -> uploadMedia(it, st).also { @@ -404,76 +337,6 @@ internal class VVODataSource( } } - override suspend fun deleteStatus(statusKey: MicroBlogKey) { - val config = service.config() - if (config.data?.login != true) { - throw LoginExpiredException( - accountKey = accountKey, - platformType = PlatformType.VVo, - ) - } - val st = config.data.st - requireNotNull(st) { "st is null" } - service.deleteStatus( - mid = statusKey.id, - st = st, - ) - database.connect { - database.statusDao().delete( - statusKey = statusKey, - accountType = AccountType.Specific(accountKey), - ) - database.statusReferenceDao().delete(statusKey) - database.pagingTimelineDao().deleteStatus( - accountKey = accountKey, - statusKey = statusKey, - ) - } - } - - override fun searchStatus(query: String) = - SearchStatusRemoteMediator( - service, - database, - accountKey, - query, - ) - - override fun searchUser( - query: String, - pageSize: Int, - ): Flow> = - Pager( - config = pagingConfig, - ) { - SearchUserPagingSource( - service = service, - accountKey = accountKey, - query = query, - ) - }.flow - - override fun discoverUsers(pageSize: Int): Flow> { - TODO("Not yet implemented") - } - - override fun discoverStatuses() = - DiscoverStatusRemoteMediator( - service, - database, - accountKey, - ) - - override fun discoverHashtags(pageSize: Int): Flow> = - Pager( - config = pagingConfig, - ) { - TrendHashtagPagingSource( - accountKey = accountKey, - service = service, - ) - }.flow - override fun composeConfig(type: ComposeType): ComposeConfig = ComposeConfig( text = ComposeConfig.Text(2000), @@ -484,490 +347,148 @@ internal class VVODataSource( altTextMaxLength = -1, allowMediaOnly = false, ), - emoji = ComposeConfig.Emoji(emoji = emoji(), mergeTag = "vvo@${accountKey.host}"), - ) - - fun emoji(): Cacheable>> = - Cacheable( - fetchSource = { - val emojis = service.emojis() - database - .emojiDao() - .insert( - DbEmoji( - accountKey.host, - EmojiContent.VVO(emojis), - ), - ) - }, - cacheSource = { - database - .emojiDao() - .get(accountKey.host) - .distinctUntilChanged() - .mapNotNull { - it - ?.toUi() - ?.groupBy { it.category } - ?.map { it.key to it.value.toImmutableList() } - ?.toMap() - ?.toImmutableMap() - } - }, + emoji = ComposeConfig.Emoji(emoji = emojiHandler.emoji, mergeTag = "vvo@${accountKey.host}"), ) - override suspend fun follow( - userKey: MicroBlogKey, - relation: UiRelation, - ) { - if (relation.following) { - unfollow(userKey) + private suspend fun like(event: PostEvent.VVO.Like) { + val st = ensureLogin() + if (event.liked) { + service.unlikeStatus(id = event.postKey.id, st = st) } else { - follow(userKey) + service.likeStatus(id = event.postKey.id, st = st) } } - override fun profileActions(): List = emptyList() - - suspend fun follow(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - following = true, - ) - } - tryRun { - val config = service.config() - if (config.data?.login != true) { - throw LoginExpiredException( - accountKey = accountKey, - platformType = PlatformType.VVo, - ) - } - val st = config.data.st - requireNotNull(st) { "st is null" } - service.follow( - st = st, - uid = userKey.id, - ) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - following = false, - ) - } + private suspend fun likeComment(event: PostEvent.VVO.LikeComment) { + val st = ensureLogin() + if (event.liked) { + service.likesDestroy(id = event.postKey.id, st = st) + } else { + service.likesUpdate(id = event.postKey.id, st = st) } } - suspend fun unfollow(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - following = false, - ) - } - tryRun { - val config = service.config() - if (config.data?.login != true) { - throw LoginExpiredException( - accountKey = accountKey, - platformType = PlatformType.VVo, - ) - } - val st = config.data.st - requireNotNull(st) { "st is null" } - service.unfollow( - st = st, - uid = userKey.id, - ) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - following = true, - ) - } + private suspend fun favorite(event: PostEvent.VVO.Favorite) { + val st = ensureLogin() + if (event.favorited) { + service.unfavoriteStatus(id = event.postKey.id, st = st) + } else { + service.favoriteStatus(id = event.postKey.id, st = st) } } - fun statusComment( - statusKey: MicroBlogKey, - scope: CoroutineScope, - ): Flow> { - val pagingKey = "status_comment_$statusKey" - return timelinePager( - pageSize = 20, - database = database, - scope = scope, - filterFlow = localFilterRepository.getFlow(forTimeline = true), - accountRepository = accountRepository, - mediator = - StatusCommentRemoteMediator( - service = service, - accountKey = accountKey, - statusKey = statusKey, - database = database, - ), + fun favouriteTimeline() = + FavouriteRemoteMediator( + service = service, + accountKey = accountKey, ) - } - fun statusRepost( - statusKey: MicroBlogKey, - scope: CoroutineScope, - ): Flow> { - val pagingKey = "status_repost_$statusKey" - return timelinePager( - pageSize = 20, - database = database, - scope = scope, - filterFlow = localFilterRepository.getFlow(forTimeline = true), - accountRepository = accountRepository, - mediator = - StatusRepostRemoteMediator( - service = service, - accountKey = accountKey, - statusKey = statusKey, - database = database, - ), + fun likeRemoteMediator() = + LikeRemoteMediator( + service = service, + accountKey = accountKey, ) - } - fun commentChild( - commentKey: MicroBlogKey, - scope: CoroutineScope, - ): Flow> { - val pagingKey = "comment_child_$commentKey" - return timelinePager( - pageSize = 20, - database = database, - scope = scope, - filterFlow = localFilterRepository.getFlow(forTimeline = true), - accountRepository = accountRepository, - mediator = - CommentChildRemoteMediator( - service = service, - accountKey = accountKey, - commentKey = commentKey, - database = database, - ), + fun statusComment(statusKey: MicroBlogKey): RemoteLoader = + StatusCommentRemoteMediator( + service = service, + accountKey = accountKey, + statusKey = statusKey, ) - } + + fun statusRepost(statusKey: MicroBlogKey): RemoteLoader = + StatusRepostRemoteMediator( + service = service, + accountKey = accountKey, + statusKey = statusKey, + ) + + fun commentChild(commentKey: MicroBlogKey): RemoteLoader = + CommentChildRemoteMediator( + service = service, + accountKey = accountKey, + commentKey = commentKey, + ) + + fun comment(statusKey: MicroBlogKey): CacheData = + MemCacheable("vvo_comment_${accountKey}_$statusKey") { + service + .getHotFlowChild(statusKey.id) + .rootComment + ?.firstOrNull() + ?.render(accountKey) + ?: throw Exception("status not found") + } fun statusExtendedText(statusKey: MicroBlogKey): Flow> = MemCacheable( "status_extended_text_$statusKey", ) { - val config = service.config() - if (config.data?.login != true) { - throw LoginExpiredException( - accountKey = accountKey, - platformType = PlatformType.VVo, - ) - } - val st = config.data.st - requireNotNull(st) { "st is null" } + val st = ensureLogin() val response = service.getStatusExtend(statusKey.id, st) response.data?.longTextContent.orEmpty() }.toUi() - override fun like( - statusKey: MicroBlogKey, - liked: Boolean, - ) { - coroutineScope.launch { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - liked = !liked, - attitudesCount = - if (liked) { - it.data.attitudesCount?.minus(1) - } else { - it.data.attitudesCount?.plus(1) - }, - ), - ) - }, - ) - - tryRun { - val config = service.config() - if (config.data?.login != true) { - throw LoginExpiredException( - accountKey = accountKey, - platformType = PlatformType.VVo, - ) - } - val st = config.data.st - requireNotNull(st) { "st is null" } - if (liked) { - service.unlikeStatus(id = statusKey.id, st = st) - } else { - service.likeStatus(id = statusKey.id, st = st) - } - }.onFailure { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - liked = liked, - attitudesCount = - if (liked) { - it.data.attitudesCount?.plus(1) - } else { - it.data.attitudesCount?.minus(1) - }, - ), - ) - }, - ) - }.onSuccess { - } - } - } + fun status(statusKey: MicroBlogKey): Flow> = + MemCacheable( + "vvo_status_$statusKey", + ) { + loadStatusDetail(statusKey) + }.toUi() - override fun likeComment( - statusKey: MicroBlogKey, - liked: Boolean, - ) { - coroutineScope.launch { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - liked = !liked, - likeCount = - if (liked) { - it.data.likeCount?.minus(1) - } else { - it.data.likeCount?.plus(1) - }, - ), - ) - }, - ) + private suspend fun uploadMedia( + fileItem: FileItem, + st: String, + ): String { + val bytes = fileItem.readBytes() + val isImage = fileItem.type == FileType.Image - tryRun { - val config = service.config() - if (config.data?.login != true) { - throw LoginExpiredException( - accountKey = accountKey, - platformType = PlatformType.VVo, - ) - } - val st = config.data.st - requireNotNull(st) { "st is null" } - if (liked) { - service.likesDestroy(id = statusKey.id, st = st) - } else { - service.likesUpdate(id = statusKey.id, st = st) - } - }.onFailure { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - liked = liked, - likeCount = - if (liked) { - it.data.likeCount?.plus(1) - } else { - it.data.likeCount?.minus(1) - }, - ), - ) - }, + val finalBytes = + if (isImage) { + imageCompressor.compress( + imageBytes = bytes, + maxSize = 5 * 1024 * 1024, + maxDimensions = 4096 to 4096, ) - }.onSuccess { + } else { + bytes } - } + val response = + service.uploadPic( + st = st, + bytes = finalBytes, + filename = fileItem.name ?: "file", + ) + return response.picID ?: throw Exception("upload failed") } - override fun favorite( - statusKey: MicroBlogKey, - favorited: Boolean, - ) { - coroutineScope.launch { - updateStatusUseCase( - statusKey = statusKey, + private suspend fun ensureLogin(): String { + val config = service.config() + if (config.data?.login != true) { + throw LoginExpiredException( accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - favorited = !favorited, - ), - ) - }, + platformType = PlatformType.VVo, ) - - tryRun { - val config = service.config() - if (config.data?.login != true) { - throw LoginExpiredException( - accountKey = accountKey, - platformType = PlatformType.VVo, - ) - } - val st = config.data.st - requireNotNull(st) { "st is null" } - if (favorited) { - service.unfavoriteStatus(id = statusKey.id, st = st) - } else { - service.favoriteStatus(id = statusKey.id, st = st) - } - }.onFailure { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - favorited = favorited, - ), - ) - }, - ) - }.onSuccess { - if (it.ok != 1L) { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - favorited = favorited, - ), - ) - }, - ) - } - } } + return requireNotNull(config.data.st) { "st is null" } } - private val notificationMarkerMentionKey: String - get() = "notificationBadgeCount_mention_$accountKey" - - private val notificationMarkerCommentKey: String - get() = "notificationBadgeCount_comment_$accountKey" - - private val notificationMarkerLikeKey: String - get() = "notificationBadgeCount_like_$accountKey" - - override fun notificationBadgeCount(): CacheData = - Cacheable( - fetchSource = { - val config = service.config() - if (config.data?.login != true) { - throw LoginExpiredException( - accountKey = accountKey, - platformType = PlatformType.VVo, - ) - } - val st = config.data.st - requireNotNull(st) { "st is null" } - val response = - service.remindUnread( - time = Clock.System.now().toEpochMilliseconds() / 1000, - st = st, - ) - val mention = response.data?.mentionStatus ?: 0 - val comment = response.data?.cmt ?: 0 - val like = response.data?.attitude ?: 0 - - MemCacheable.update(notificationMarkerMentionKey, mention) - MemCacheable.update(notificationMarkerCommentKey, comment) - MemCacheable.update(notificationMarkerLikeKey, like) - }, - cacheSource = { - val mentionFlow = MemCacheable.subscribe(notificationMarkerMentionKey) - val commentFlow = MemCacheable.subscribe(notificationMarkerCommentKey) - val likeFlow = MemCacheable.subscribe(notificationMarkerLikeKey) - combine(mentionFlow, commentFlow, likeFlow) { mention, comment, like -> - (mention + comment + like).toInt() - } - }, - ) - - override fun following( - userKey: MicroBlogKey, - scope: CoroutineScope, - pageSize: Int, - ): Flow> = - Pager( - config = pagingConfig, - ) { - FollowingPagingSource( - service = service, - userKey = userKey, - accountKey = accountKey, - ) - }.flow.cachedIn(scope) - - override fun fans( - userKey: MicroBlogKey, - scope: CoroutineScope, - pageSize: Int, - ): Flow> = - Pager( - config = pagingConfig, - ) { - FansPagingSource( - service = service, - userKey = userKey, - accountKey = accountKey, - ) - }.flow.cachedIn(scope) - - override fun profileTabs(userKey: MicroBlogKey): ImmutableList = - persistentListOf( - ProfileTab.Timeline( - type = ProfileTab.Timeline.Type.Status, - loader = userTimeline(userKey, false), - ), - ) - - fun favouriteTimeline() = - FavouriteRemoteMediator( - service = service, - database = database, - accountKey = accountKey, - ) - - fun likeRemoteMediator() = - LikeRemoteMediator( - service = service, - database = database, - accountKey = accountKey, - ) + private suspend fun loadStatusDetail(statusKey: MicroBlogKey): UiTimelineV2 { + val regex = + "\\\$render_data\\s*=\\s*(\\[\\{.*?\\}\\])\\[0\\]\\s*\\|\\|\\s*\\{\\};".toRegex() + val response = + service + .getStatusDetail(statusKey.id) + .split("\n") + .joinToString("") + val json = + regex + .find(response) + ?.groupValues + ?.get(1) + ?.decodeJson>() + ?: throw Exception("status not found") + + return json.firstOrNull()?.status?.render(accountKey) ?: throw Exception("status not found") + } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/VVOLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/VVOLoader.kt new file mode 100644 index 000000000..32362aaaa --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/VVOLoader.kt @@ -0,0 +1,165 @@ +package dev.dimension.flare.data.datasource.vvo + +import dev.dimension.flare.common.decodeJson +import dev.dimension.flare.data.datasource.microblog.loader.EmojiLoader +import dev.dimension.flare.data.datasource.microblog.loader.NotificationLoader +import dev.dimension.flare.data.datasource.microblog.loader.PostLoader +import dev.dimension.flare.data.datasource.microblog.loader.RelationActionType +import dev.dimension.flare.data.datasource.microblog.loader.RelationLoader +import dev.dimension.flare.data.datasource.microblog.loader.UserLoader +import dev.dimension.flare.data.network.vvo.VVOService +import dev.dimension.flare.data.network.vvo.model.StatusDetailItem +import dev.dimension.flare.data.repository.LoginExpiredException +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.model.UiEmoji +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 dev.dimension.flare.ui.model.mapper.render +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap +import kotlin.time.Clock + +internal class VVOLoader( + val accountKey: MicroBlogKey, + private val service: VVOService, +) : NotificationLoader, + UserLoader, + RelationLoader, + EmojiLoader, + PostLoader { + override val supportedTypes: Set = setOf(RelationActionType.Follow) + + override suspend fun notificationBadgeCount(): Int { + val st = ensureLogin() + val response = + service.remindUnread( + time = Clock.System.now().toEpochMilliseconds() / 1000, + st = st, + ) + val mention = response.data?.mentionStatus ?: 0 + val comment = response.data?.cmt ?: 0 + val like = response.data?.attitude ?: 0 + return (mention + comment + like).toInt() + } + + override suspend fun userByHandleAndHost(uiHandle: UiHandle): UiProfile { + val uid = service.getUid(uiHandle.normalizedRaw) ?: error("user not found") + return userById(uid) + } + + override suspend fun userById(id: String): UiProfile { + val st = ensureLogin() + val profile = service.profileInfo(id, st) + val user = profile.data?.user ?: error("user not found") + return user.render(accountKey) + } + + override suspend fun relation(userKey: MicroBlogKey): UiRelation { + val st = ensureLogin() + val profile = service.profileInfo(userKey.id, st) + val user = profile.data?.user ?: error("user not found") + return UiRelation( + following = user.following, + isFans = user.followMe ?: false, + ) + } + + override suspend fun follow(userKey: MicroBlogKey) { + val st = ensureLogin() + service.follow( + st = st, + uid = userKey.id, + ) + } + + override suspend fun unfollow(userKey: MicroBlogKey) { + val st = ensureLogin() + service.unfollow( + st = st, + uid = userKey.id, + ) + } + + override suspend fun block(userKey: MicroBlogKey) { + error("VVO does not support block") + } + + override suspend fun unblock(userKey: MicroBlogKey) { + error("VVO does not support unblock") + } + + override suspend fun mute(userKey: MicroBlogKey) { + error("VVO does not support mute") + } + + override suspend fun unmute(userKey: MicroBlogKey) { + error("VVO does not support unmute") + } + + override suspend fun emojis(): ImmutableMap> = + service + .emojis() + .data + ?.emoticon + ?.zhCN + .orEmpty() + .map { (category, values) -> + category to + values + .mapNotNull { emoji -> + val phrase = emoji.phrase ?: return@mapNotNull null + val url = emoji.url ?: return@mapNotNull null + UiEmoji( + shortcode = phrase, + url = url, + category = category, + searchKeywords = listOf(phrase).toImmutableList(), + insertText = "$phrase ", + ) + }.toImmutableList() + }.toMap() + .toImmutableMap() + + override suspend fun status(statusKey: MicroBlogKey): UiTimelineV2 { + val regex = + "\\\$render_data\\s*=\\s*(\\[\\{.*?\\}\\])\\[0\\]\\s*\\|\\|\\s*\\{\\};".toRegex() + val response = + service + .getStatusDetail(statusKey.id) + .split("\n") + .joinToString("") + val json = + regex + .find(response) + ?.groupValues + ?.get(1) + ?.decodeJson>() + ?: throw Exception("status not found") + + return json.firstOrNull()?.status?.render(accountKey) ?: throw Exception("status not found") + } + + override suspend fun deleteStatus(statusKey: MicroBlogKey) { + val st = ensureLogin() + service.deleteStatus( + mid = statusKey.id, + st = st, + ) + } + + private suspend fun ensureLogin(): String { + val config = service.config() + if (config.data?.login != true) { + throw LoginExpiredException( + accountKey = accountKey, + platformType = PlatformType.VVo, + ) + } + return requireNotNull(config.data.st) { "st is null" } + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/DeviceFollowRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/DeviceFollowRemoteMediator.kt index 43f8fcabb..a493fa1bb 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/DeviceFollowRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/DeviceFollowRemoteMediator.kt @@ -1,38 +1,33 @@ package dev.dimension.flare.data.datasource.xqt import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.mapper.cursor -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline import dev.dimension.flare.data.database.cache.mapper.tweets -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.xqt.XQTService import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class DeviceFollowRemoteMediator( private val service: XQTService, - database: CacheDatabase, private val accountKey: MicroBlogKey, -) : BaseTimelineRemoteMediator( - database = database, - ) { - override val pagingKey = "device_follow_$accountKey" +) : CacheableRemoteLoader { + override val pagingKey: String = "device_follow_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { - service - .getNotificationsDeviceFollow( - count = pageSize, - ) + service.getNotificationsDeviceFollow( + count = pageSize, + ) } is PagingRequest.Prepend -> { @@ -50,11 +45,9 @@ internal class DeviceFollowRemoteMediator( } val tweets = response.tweets() - val data = tweets.mapNotNull { it.toDbPagingTimeline(accountKey, pagingKey) } - return PagingResult( endOfPaginationReached = tweets.isEmpty(), - data = data, + data = tweets.mapNotNull { it.render(accountKey) }, nextKey = response.cursor(), ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/FansPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/FansPagingSource.kt index 314a116d0..77717b1cd 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/FansPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/FansPagingSource.kt @@ -1,10 +1,11 @@ package dev.dimension.flare.data.datasource.xqt -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource import dev.dimension.flare.common.encodeJson import dev.dimension.flare.data.database.cache.mapper.cursor import dev.dimension.flare.data.database.cache.mapper.users +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.xqt.XQTService import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiProfile @@ -17,19 +18,28 @@ internal class FansPagingSource( private val service: XQTService, private val accountKey: MicroBlogKey, private val userKey: MicroBlogKey, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null - - override suspend fun doLoad(params: LoadParams): LoadResult { - val cursor = params.key - val limit = params.loadSize +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + val cursor = + when (request) { + PagingRequest.Refresh -> null + is PagingRequest.Prepend -> { + return PagingResult( + endOfPaginationReached = true, + ) + } + is PagingRequest.Append -> request.nextKey + } val response = service .getFollowers( variables = FollowVar( userID = userKey.id, - count = limit.toLong(), + count = pageSize.toLong(), cursor = cursor, ).encodeJson(), ).body() @@ -52,12 +62,8 @@ internal class FansPagingSource( ?.timeline ?.instructions ?.cursor() - return LoadResult.Page( - data = - users.map { - it.render(accountKey = accountKey) - }, - prevKey = null, + return PagingResult( + data = users.map { it.render(accountKey = accountKey) }, nextKey = nextCursor, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/FollowingPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/FollowingPagingSource.kt index 228c8dfcf..b238ddbf8 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/FollowingPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/FollowingPagingSource.kt @@ -1,10 +1,11 @@ package dev.dimension.flare.data.datasource.xqt -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource import dev.dimension.flare.common.encodeJson import dev.dimension.flare.data.database.cache.mapper.cursor import dev.dimension.flare.data.database.cache.mapper.users +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.xqt.XQTService import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiProfile @@ -14,19 +15,28 @@ internal class FollowingPagingSource( private val service: XQTService, private val accountKey: MicroBlogKey, private val userKey: MicroBlogKey, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null - - override suspend fun doLoad(params: LoadParams): LoadResult { - val cursor = params.key - val limit = params.loadSize +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + val cursor = + when (request) { + PagingRequest.Refresh -> null + is PagingRequest.Prepend -> { + return PagingResult( + endOfPaginationReached = true, + ) + } + is PagingRequest.Append -> request.nextKey + } val response = service .getFollowing( variables = FollowVar( userID = userKey.id, - count = limit.toLong(), + count = pageSize.toLong(), cursor = cursor, ).encodeJson(), ).body() @@ -49,12 +59,8 @@ internal class FollowingPagingSource( ?.timeline ?.instructions ?.cursor() - return LoadResult.Page( - data = - users.map { - it.render(accountKey = accountKey) - }, - prevKey = null, + return PagingResult( + data = users.map { it.render(accountKey = accountKey) }, nextKey = nextCursor, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/HomeTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/HomeTimelineRemoteMediator.kt index 59b4c00a1..f6833b6e8 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/HomeTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/HomeTimelineRemoteMediator.kt @@ -4,91 +4,82 @@ import androidx.paging.ExperimentalPagingApi import dev.dimension.flare.common.InAppNotification import dev.dimension.flare.common.Message import dev.dimension.flare.common.encodeJson -import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.mapper.cursor -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline import dev.dimension.flare.data.database.cache.mapper.tweets -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.xqt.XQTService import dev.dimension.flare.data.repository.LoginExpiredException import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render import kotlinx.serialization.Required import kotlinx.serialization.Serializable @OptIn(ExperimentalPagingApi::class) internal class HomeTimelineRemoteMediator( private val service: XQTService, - database: CacheDatabase, private val accountKey: MicroBlogKey, private val inAppNotification: InAppNotification, -) : BaseTimelineRemoteMediator( - database = database, - ) { - override val pagingKey = "home_$accountKey" +) : CacheableRemoteLoader { + override val pagingKey: String = "home_$accountKey" - override suspend fun initialize(): InitializeAction = InitializeAction.SKIP_INITIAL_REFRESH - - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { - val response = - when (request) { - PagingRequest.Refresh -> { - service - .getHomeLatestTimeline( + ): PagingResult { + return try { + val response = + when (request) { + PagingRequest.Refresh -> { + service.getHomeLatestTimeline( variables = HomeTimelineRequest( count = pageSize.toLong(), ).encodeJson(), ) - } - - is PagingRequest.Prepend -> { - return PagingResult( - endOfPaginationReached = true, - ) - } - - is PagingRequest.Append -> { - service.getHomeLatestTimeline( - variables = - HomeTimelineRequest( - count = pageSize.toLong(), - cursor = request.nextKey, - ).encodeJson(), - ) - } - }.body() - val instructions = - response - ?.data - ?.home - ?.homeTimelineUrt - ?.instructions - .orEmpty() - val cursor = instructions.cursor() - val tweet = instructions.tweets() - - val data = tweet.mapNotNull { it.toDbPagingTimeline(accountKey, pagingKey) } + } - return PagingResult( - endOfPaginationReached = tweet.isEmpty(), - data = data, - nextKey = cursor, - ) - } + is PagingRequest.Prepend -> { + return PagingResult( + endOfPaginationReached = true, + ) + } - override fun onError(e: Throwable) { - super.onError(e) - if (e is LoginExpiredException) { - inAppNotification.onError( - Message.LoginExpired, - e, + is PagingRequest.Append -> { + service.getHomeLatestTimeline( + variables = + HomeTimelineRequest( + count = pageSize.toLong(), + cursor = request.nextKey, + ).encodeJson(), + ) + } + }.body() + val instructions = + response + ?.data + ?.home + ?.homeTimelineUrt + ?.instructions + .orEmpty() + val cursor = instructions.cursor() + val tweet = instructions.tweets() + + PagingResult( + endOfPaginationReached = tweet.isEmpty(), + data = tweet.mapNotNull { it.render(accountKey) }, + nextKey = cursor, ) + } catch (e: Throwable) { + if (e is LoginExpiredException) { + inAppNotification.onError( + Message.LoginExpired, + e, + ) + } + throw e } } } @@ -96,29 +87,23 @@ internal class HomeTimelineRemoteMediator( @OptIn(ExperimentalPagingApi::class) internal class FeaturedTimelineRemoteMediator( private val service: XQTService, - database: CacheDatabase, private val accountKey: MicroBlogKey, -) : BaseTimelineRemoteMediator( - database = database, - ) { - override val pagingKey = "featured_$accountKey" - - override suspend fun initialize(): InitializeAction = InitializeAction.SKIP_INITIAL_REFRESH +) : CacheableRemoteLoader { + override val pagingKey: String = "featured_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { - service - .getHomeTimeline( - variables = - HomeTimelineRequest( - count = pageSize.toLong(), - ).encodeJson(), - ) + service.getHomeTimeline( + variables = + HomeTimelineRequest( + count = pageSize.toLong(), + ).encodeJson(), + ) } is PagingRequest.Prepend -> { @@ -146,11 +131,9 @@ internal class FeaturedTimelineRemoteMediator( .orEmpty() val tweet = instructions.tweets() - val data = tweet.mapNotNull { it.toDbPagingTimeline(accountKey, pagingKey) } - return PagingResult( endOfPaginationReached = tweet.isEmpty(), - data = data, + data = tweet.mapNotNull { it.render(accountKey) }, nextKey = instructions.cursor(), ) } @@ -159,27 +142,23 @@ internal class FeaturedTimelineRemoteMediator( @OptIn(ExperimentalPagingApi::class) internal class BookmarkTimelineRemoteMediator( private val service: XQTService, - database: CacheDatabase, private val accountKey: MicroBlogKey, -) : BaseTimelineRemoteMediator( - database = database, - ) { - override val pagingKey = "bookmark_$accountKey" +) : CacheableRemoteLoader { + override val pagingKey: String = "bookmark_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { - service - .getBookmarks( - variables = - HomeTimelineRequest( - count = pageSize.toLong(), - ).encodeJson(), - ) + service.getBookmarks( + variables = + HomeTimelineRequest( + count = pageSize.toLong(), + ).encodeJson(), + ) } is PagingRequest.Prepend -> { @@ -207,11 +186,9 @@ internal class BookmarkTimelineRemoteMediator( .orEmpty() val tweet = instructions.tweets() - val data = tweet.mapNotNull { it.toDbPagingTimeline(accountKey, pagingKey) } - return PagingResult( endOfPaginationReached = tweet.isEmpty(), - data = data, + data = tweet.mapNotNull { it.render(accountKey) }, nextKey = instructions.cursor(), ) } @@ -220,10 +197,16 @@ internal class BookmarkTimelineRemoteMediator( @Serializable internal data class HomeTimelineRequest( @Required - val count: Long = 20, + val count: Long? = null, val cursor: String? = null, @Required - val includePromotedContent: Boolean = false, + val includePromotedContent: Boolean = true, + @Required + val latestControlAvailable: Boolean = true, + @Required + val requestContext: String = "launch", + @Required + val withCommunity: Boolean = true, @Required - val latestControlAvailable: Boolean = false, + val seenTweetIds: List = emptyList(), ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/ListTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/ListTimelineRemoteMediator.kt index 896f825ee..f117983bb 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/ListTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/ListTimelineRemoteMediator.kt @@ -2,16 +2,15 @@ package dev.dimension.flare.data.datasource.xqt import androidx.paging.ExperimentalPagingApi import dev.dimension.flare.common.encodeJson -import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.mapper.cursor -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline import dev.dimension.flare.data.database.cache.mapper.tweets -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.xqt.XQTService import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -19,12 +18,9 @@ import kotlinx.serialization.Serializable internal class ListTimelineRemoteMediator( private val listId: String, private val service: XQTService, - private val database: CacheDatabase, private val accountKey: MicroBlogKey, -) : BaseTimelineRemoteMediator( - database = database, - ) { - override val pagingKey = "list_${listId}_$accountKey" +) : CacheableRemoteLoader { + override val pagingKey: String = "list_${listId}_$accountKey" @Serializable data class Request( @@ -34,21 +30,20 @@ internal class ListTimelineRemoteMediator( val cursor: String? = null, ) - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { - service - .getListLatestTweetsTimeline( - variables = - Request( - listID = listId, - count = pageSize.toLong(), - ).encodeJson(), - ) + service.getListLatestTweetsTimeline( + variables = + Request( + listID = listId, + count = pageSize.toLong(), + ).encodeJson(), + ) } is PagingRequest.Prepend -> { @@ -70,11 +65,9 @@ internal class ListTimelineRemoteMediator( }.body()?.data?.list?.tweetsTimeline?.timeline?.instructions.orEmpty() val result = response.tweets() - val data = result.mapNotNull { it.toDbPagingTimeline(accountKey, pagingKey) } - return PagingResult( endOfPaginationReached = response.isEmpty(), - data = data, + data = result.mapNotNull { it.render(accountKey) }, nextKey = response.cursor(), ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/MentionRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/MentionRemoteMediator.kt index d56913f1d..83c3426ed 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/MentionRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/MentionRemoteMediator.kt @@ -1,38 +1,33 @@ package dev.dimension.flare.data.datasource.xqt import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.mapper.cursor -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline import dev.dimension.flare.data.database.cache.mapper.tweets -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.xqt.XQTService import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class MentionRemoteMediator( private val service: XQTService, - database: CacheDatabase, private val accountKey: MicroBlogKey, -) : BaseTimelineRemoteMediator( - database = database, - ) { - override val pagingKey = "mention_$accountKey" +) : CacheableRemoteLoader { + override val pagingKey: String = "mention_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { - service - .getNotificationsMentions( - count = pageSize, - ) + service.getNotificationsMentions( + count = pageSize, + ) } is PagingRequest.Prepend -> { @@ -50,11 +45,9 @@ internal class MentionRemoteMediator( } val tweets = response.tweets() - val data = tweets.mapNotNull { it.toDbPagingTimeline(accountKey, pagingKey) } - return PagingResult( endOfPaginationReached = tweets.isEmpty(), - data = data, + data = tweets.mapNotNull { it.render(accountKey) }, nextKey = response.cursor(), ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/NotificationPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/NotificationPagingSource.kt index f484f209a..f52a8daac 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/NotificationPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/NotificationPagingSource.kt @@ -1,29 +1,41 @@ package dev.dimension.flare.data.datasource.xqt -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource import dev.dimension.flare.data.database.cache.mapper.cursor -import dev.dimension.flare.data.datasource.microblog.StatusEvent +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.xqt.XQTService import dev.dimension.flare.data.network.xqt.model.CursorType import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.model.mapper.renderNotifications internal class NotificationPagingSource( private val locale: String, private val service: XQTService, - private val event: StatusEvent.XQT, private val accountKey: MicroBlogKey, private val onClearMarker: () -> Unit, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null +) : CacheableRemoteLoader { + override val pagingKey: String = "notification_$accountKey" - override suspend fun doLoad(params: LoadParams): LoadResult { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + val cursor = + when (request) { + PagingRequest.Refresh -> null + is PagingRequest.Prepend -> { + return PagingResult( + endOfPaginationReached = true, + ) + } + is PagingRequest.Append -> request.nextKey + } val response = service.getNotificationsAll( xTwitterClientLanguage = locale, - cursor = params.key, + cursor = cursor, ) val topCursor = response.cursor(type = CursorType.top) @@ -31,15 +43,16 @@ internal class NotificationPagingSource( service.postNotificationsAllLastSeenCursor(topCursor) } - onClearMarker.invoke() + if (request == PagingRequest.Refresh) { + onClearMarker.invoke() + } - val notifications = response.renderNotifications(accountKey, event) - val cursor = response.cursor() + val notifications = response.renderNotifications(accountKey) + val nextCursor = response.cursor() - return LoadResult.Page( + return PagingResult( data = notifications, - prevKey = null, - nextKey = cursor.takeIf { it != params.key }, + nextKey = nextCursor.takeIf { it != cursor }, ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/SearchStatusPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/SearchStatusPagingSource.kt index 638051d11..5931094fe 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/SearchStatusPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/SearchStatusPagingSource.kt @@ -1,76 +1,60 @@ package dev.dimension.flare.data.datasource.xqt -import androidx.paging.ExperimentalPagingApi import dev.dimension.flare.common.encodeJson -import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.mapper.cursor import dev.dimension.flare.data.database.cache.mapper.isBottomEnd -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline import dev.dimension.flare.data.database.cache.mapper.tweets -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.xqt.XQTService import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render import io.ktor.http.encodeURLQueryComponent import kotlinx.serialization.Required import kotlinx.serialization.Serializable -@OptIn(ExperimentalPagingApi::class) internal class SearchStatusPagingSource( private val service: XQTService, - database: CacheDatabase, private val accountKey: MicroBlogKey, private val query: String, -) : BaseTimelineRemoteMediator( - database = database, - ) { - override val pagingKey = "search_status_$query" - - override suspend fun timeline( +) : RemoteLoader { + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { - val response = + ): PagingResult { + val cursor = when (request) { - PagingRequest.Refresh -> { - service - .getSearchTimeline( - variables = - SearchRequest( - rawQuery = query, - count = pageSize.toLong(), - ).encodeJson(), - referer = "https://${accountKey.host}/search?q=${query.encodeURLQueryComponent()}", - ) - } - + PagingRequest.Refresh -> null is PagingRequest.Prepend -> { return PagingResult( endOfPaginationReached = true, ) } - - is PagingRequest.Append -> { - service.getSearchTimeline( - variables = - SearchRequest( - rawQuery = query, - count = pageSize.toLong(), - cursor = request.nextKey, - ).encodeJson(), - referer = "https://${accountKey.host}/search?q=${query.encodeURLQueryComponent()}", - ) - } - }.body()?.data?.searchByRawQuery?.searchTimeline?.timeline?.instructions.orEmpty() + is PagingRequest.Append -> request.nextKey + } + val response = + service + .getSearchTimeline( + variables = + SearchRequest( + rawQuery = query, + count = pageSize.toLong(), + cursor = cursor, + ).encodeJson(), + referer = "https://${accountKey.host}/search?q=${query.encodeURLQueryComponent()}", + ).body() + ?.data + ?.searchByRawQuery + ?.searchTimeline + ?.timeline + ?.instructions + .orEmpty() val tweets = response.tweets() - - val data = tweets.mapNotNull { it.toDbPagingTimeline(accountKey, pagingKey) } - return PagingResult( endOfPaginationReached = response.isBottomEnd(), - data = data, + data = tweets.mapNotNull { it.render(accountKey) }, nextKey = response.cursor(), ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/SearchUserPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/SearchUserPagingSource.kt index e75837d14..d459f8d80 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/SearchUserPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/SearchUserPagingSource.kt @@ -1,11 +1,12 @@ package dev.dimension.flare.data.datasource.xqt -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource import dev.dimension.flare.common.encodeJson import dev.dimension.flare.data.database.cache.mapper.cursor import dev.dimension.flare.data.database.cache.mapper.isBottomEnd import dev.dimension.flare.data.database.cache.mapper.users +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.xqt.XQTService import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiProfile @@ -16,18 +17,29 @@ internal class SearchUserPagingSource( private val service: XQTService, private val accountKey: MicroBlogKey, private val query: String, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null - - override suspend fun doLoad(params: LoadParams): LoadResult { +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + val cursor = + when (request) { + PagingRequest.Refresh -> null + is PagingRequest.Prepend -> { + return PagingResult( + endOfPaginationReached = true, + ) + } + is PagingRequest.Append -> request.nextKey + } val response = service .getSearchTimeline( variables = SearchRequest( rawQuery = query, - count = params.loadSize.toLong(), - cursor = params.key, + count = pageSize.toLong(), + cursor = cursor, product = "People", ).encodeJson(), referer = "https://${accountKey.host}/search?q=${query.encodeURLQueryComponent()}", @@ -38,17 +50,11 @@ internal class SearchUserPagingSource( ?.timeline ?.instructions .orEmpty() - val cursor = response.cursor() + val nextKey = response.cursor() val users = response.users() - return LoadResult.Page( + return PagingResult( data = users.map { it.render(accountKey = accountKey) }, - prevKey = null, - nextKey = - if (response.isBottomEnd() || users.isEmpty()) { - null - } else { - cursor - }, + nextKey = if (response.isBottomEnd() || users.isEmpty()) null else nextKey, ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/StatusDetailRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/StatusDetailRemoteMediator.kt index b21bdbf85..6d7979874 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/StatusDetailRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/StatusDetailRemoteMediator.kt @@ -2,25 +2,19 @@ package dev.dimension.flare.data.datasource.xqt import androidx.paging.ExperimentalPagingApi import dev.dimension.flare.common.encodeJson -import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.connect import dev.dimension.flare.data.database.cache.mapper.cursor import dev.dimension.flare.data.database.cache.mapper.isBottomEnd -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline import dev.dimension.flare.data.database.cache.mapper.tweets -import dev.dimension.flare.data.database.cache.model.DbPagingTimeline -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.StatusEvent -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.xqt.XQTService import dev.dimension.flare.data.network.xqt.model.Tweet import dev.dimension.flare.data.network.xqt.model.TweetTombstone import dev.dimension.flare.data.network.xqt.model.TweetWithVisibilityResults -import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey -import kotlinx.coroutines.flow.firstOrNull +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render import kotlinx.serialization.Required import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -29,13 +23,9 @@ import kotlinx.serialization.Serializable internal class StatusDetailRemoteMediator( private val statusKey: MicroBlogKey, private val service: XQTService, - private val database: CacheDatabase, private val accountKey: MicroBlogKey, - private val event: StatusEvent.XQT, private val statusOnly: Boolean, -) : BaseTimelineRemoteMediator( - database = database, - ) { +) : CacheableRemoteLoader { override val pagingKey: String = buildString { append("status_detail_") @@ -46,16 +36,17 @@ internal class StatusDetailRemoteMediator( append("_") append(accountKey.toString()) } + private var conversationId: String? = null - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult = when (request) { is PagingRequest.Append -> { if (statusOnly) { - return PagingResult( + PagingResult( endOfPaginationReached = true, ) } else { @@ -108,40 +99,25 @@ internal class StatusDetailRemoteMediator( is TweetWithVisibilityResults -> result.tweet.legacy?.conversationIdStr == conversationId } } - val result = actualTweet.mapNotNull { it.toDbPagingTimeline(accountKey, pagingKey) } - return PagingResult( + PagingResult( endOfPaginationReached = response.isBottomEnd() || actualTweet.size == 1 || response.cursor() == null, - data = result, + data = + actualTweet.mapNotNull { + it.render(accountKey) + }, nextKey = response.cursor(), ) } } + is PagingRequest.Prepend -> { - return PagingResult( + PagingResult( endOfPaginationReached = true, ) } - PagingRequest.Refresh -> { - if (!database.pagingTimelineDao().existsPaging(accountKey, pagingKey)) { - database.statusDao().get(statusKey, AccountType.Specific(accountKey)).firstOrNull()?.let { - database.connect { - database - .pagingTimelineDao() - .insertAll( - listOf( - DbPagingTimeline( - accountType = AccountType.Specific(accountKey), - statusKey = statusKey, - pagingKey = pagingKey, - sortId = 0, - ), - ), - ) - } - } - } + PagingRequest.Refresh -> { val response = service .getTweetDetail( @@ -157,16 +133,17 @@ internal class StatusDetailRemoteMediator( .orEmpty() val tweet = response.tweets() val item = tweet.firstOrNull { it.id == statusKey.id } - val result = listOf(item).mapNotNull { it?.toDbPagingTimeline(accountKey, pagingKey) } - return PagingResult( + PagingResult( endOfPaginationReached = statusOnly, - data = result, - nextKey = "", + data = + listOfNotNull(item).mapNotNull { + it.render(accountKey) + }, + nextKey = if (statusOnly) null else "", ) } } - } } @Serializable @@ -186,7 +163,6 @@ internal data class TweetDetailRequest( @SerialName("focalTweetId") val focalTweetID: String, val cursor: String? = null, - // tweet/profile/home @Required val referrer: String = "tweet", @SerialName("with_rux_injections") diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/TrendHashtagPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/TrendHashtagPagingSource.kt index d7d0640f5..e2b20152b 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/TrendHashtagPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/TrendHashtagPagingSource.kt @@ -1,43 +1,48 @@ package dev.dimension.flare.data.datasource.xqt -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.xqt.XQTService import dev.dimension.flare.ui.model.UiHashtag internal class TrendHashtagPagingSource( private val service: XQTService, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): Int? = null - - override suspend fun doLoad(params: LoadParams): LoadResult { - service - .getGuide(count = params.loadSize) - .timeline - ?.instructions - ?.asSequence() - ?.mapNotNull { - it.addEntries?.entries - }?.flatten() - ?.mapNotNull { - it.content?.timelineModule?.items - }?.flatten() - ?.mapNotNull { - it.item?.content?.trend - }?.map { - UiHashtag( - hashtag = it.name.orEmpty(), - description = null, - searchContent = it.name.orEmpty(), - ) - }?.toList() - .orEmpty() - .let { - return LoadResult.Page( - data = it, - prevKey = null, - nextKey = null, - ) - } +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend || request is PagingRequest.Append) { + return PagingResult( + endOfPaginationReached = true, + ) + } + val data = + service + .getGuide(count = pageSize) + .timeline + ?.instructions + ?.asSequence() + ?.mapNotNull { + it.addEntries?.entries + }?.flatten() + ?.mapNotNull { + it.content?.timelineModule?.items + }?.flatten() + ?.mapNotNull { + it.item?.content?.trend + }?.map { + UiHashtag( + hashtag = it.name.orEmpty(), + description = null, + searchContent = it.name.orEmpty(), + ) + }?.toList() + .orEmpty() + return PagingResult( + data = data, + endOfPaginationReached = true, + ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/TrendsUserPagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/TrendsUserPagingSource.kt index 3c88d0764..b5cf74830 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/TrendsUserPagingSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/TrendsUserPagingSource.kt @@ -1,7 +1,8 @@ package dev.dimension.flare.data.datasource.xqt -import androidx.paging.PagingState -import dev.dimension.flare.common.BasePagingSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.network.xqt.XQTService import dev.dimension.flare.data.network.xqt.model.User import dev.dimension.flare.model.MicroBlogKey @@ -11,29 +12,34 @@ import dev.dimension.flare.ui.model.mapper.render internal class TrendsUserPagingSource( private val service: XQTService, private val accountKey: MicroBlogKey, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): Int? = null - - override suspend fun doLoad(params: LoadParams): LoadResult { - service - .getUserRecommendations( - limit = params.loadSize, - userId = accountKey.id, - ).mapNotNull { - if (it.user != null && it.userID != null) { - User( - legacy = it.user, - restId = it.userID, - ).render(accountKey = accountKey) - } else { - null +) : RemoteLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend || request is PagingRequest.Append) { + return PagingResult( + endOfPaginationReached = true, + ) + } + val data = + service + .getUserRecommendations( + limit = pageSize, + userId = accountKey.id, + ).mapNotNull { + if (it.user != null && it.userID != null) { + User( + legacy = it.user, + restId = it.userID, + ).render(accountKey = accountKey) + } else { + null + } } - }.let { - return LoadResult.Page( - data = it, - prevKey = null, - nextKey = null, - ) - } + return PagingResult( + data = data, + endOfPaginationReached = true, + ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/UserLikesTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/UserLikesTimelineRemoteMediator.kt index 19e3a6fed..9e72f5d35 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/UserLikesTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/UserLikesTimelineRemoteMediator.kt @@ -2,43 +2,38 @@ package dev.dimension.flare.data.datasource.xqt import androidx.paging.ExperimentalPagingApi import dev.dimension.flare.common.encodeJson -import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.mapper.cursor -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline import dev.dimension.flare.data.database.cache.mapper.tweets -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.xqt.XQTService import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class UserLikesTimelineRemoteMediator( private val userKey: MicroBlogKey, private val service: XQTService, - private val database: CacheDatabase, private val accountKey: MicroBlogKey, -) : BaseTimelineRemoteMediator( - database = database, - ) { - override val pagingKey = "user_likes_${userKey}_$accountKey" +) : CacheableRemoteLoader { + override val pagingKey: String = "user_likes_${userKey}_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { - service - .getLikes( - variables = - UserTimelineRequest( - userID = userKey.id, - count = pageSize.toLong(), - ).encodeJson(), - ) + service.getLikes( + variables = + UserTimelineRequest( + userID = userKey.id, + count = pageSize.toLong(), + ).encodeJson(), + ) } is PagingRequest.Prepend -> { @@ -72,11 +67,9 @@ internal class UserLikesTimelineRemoteMediator( includePin = request is PagingRequest.Refresh, ) - val data = tweet.mapNotNull { it.toDbPagingTimeline(accountKey, pagingKey) } - return PagingResult( endOfPaginationReached = tweet.isEmpty(), - data = data, + data = tweet.mapNotNull { it.render(accountKey) }, nextKey = instructions.cursor(), ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/UserMediaTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/UserMediaTimelineRemoteMediator.kt index 865e0d162..604f7c668 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/UserMediaTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/UserMediaTimelineRemoteMediator.kt @@ -2,43 +2,38 @@ package dev.dimension.flare.data.datasource.xqt import androidx.paging.ExperimentalPagingApi import dev.dimension.flare.common.encodeJson -import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.mapper.cursor -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline import dev.dimension.flare.data.database.cache.mapper.tweets -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.xqt.XQTService import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class UserMediaTimelineRemoteMediator( private val userKey: MicroBlogKey, private val service: XQTService, - private val database: CacheDatabase, private val accountKey: MicroBlogKey, -) : BaseTimelineRemoteMediator( - database = database, - ) { - override val pagingKey = "user_media_${userKey}_$accountKey" +) : CacheableRemoteLoader { + override val pagingKey: String = "user_media_${userKey}_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { - service - .getUserMedia( - variables = - UserTimelineRequest( - userID = userKey.id, - count = pageSize.toLong(), - ).encodeJson(), - ) + service.getUserMedia( + variables = + UserTimelineRequest( + userID = userKey.id, + count = pageSize.toLong(), + ).encodeJson(), + ) } is PagingRequest.Prepend -> { @@ -72,20 +67,9 @@ internal class UserMediaTimelineRemoteMediator( includePin = request is PagingRequest.Refresh, ) - val data = - tweet.mapNotNull { - it.toDbPagingTimeline( - accountKey = accountKey, - pagingKey = pagingKey, - sortIdProvider = { tweet -> - tweet.id?.toLong() ?: tweet.sortedIndex - }, - ) - } - return PagingResult( endOfPaginationReached = tweet.isEmpty(), - data = data, + data = tweet.mapNotNull { it.render(accountKey) }, nextKey = instructions.cursor(), ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/UserRepliesTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/UserRepliesTimelineRemoteMediator.kt index fe2897bac..473a828cb 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/UserRepliesTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/UserRepliesTimelineRemoteMediator.kt @@ -2,43 +2,38 @@ package dev.dimension.flare.data.datasource.xqt import androidx.paging.ExperimentalPagingApi import dev.dimension.flare.common.encodeJson -import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.mapper.cursor -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline import dev.dimension.flare.data.database.cache.mapper.tweets -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.xqt.XQTService import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render @OptIn(ExperimentalPagingApi::class) internal class UserRepliesTimelineRemoteMediator( private val userKey: MicroBlogKey, private val service: XQTService, - private val database: CacheDatabase, private val accountKey: MicroBlogKey, -) : BaseTimelineRemoteMediator( - database = database, - ) { - override val pagingKey = "user_replies_${userKey}_$accountKey" +) : CacheableRemoteLoader { + override val pagingKey: String = "user_replies_${userKey}_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { - service - .getUserTweetsAndReplies( - variables = - UserTimelineRequest( - userID = userKey.id, - count = pageSize.toLong(), - ).encodeJson(), - ) + service.getUserTweetsAndReplies( + variables = + UserTimelineRequest( + userID = userKey.id, + count = pageSize.toLong(), + ).encodeJson(), + ) } is PagingRequest.Prepend -> { @@ -72,11 +67,9 @@ internal class UserRepliesTimelineRemoteMediator( includePin = request is PagingRequest.Refresh, ) - val data = tweet.mapNotNull { it.toDbPagingTimeline(accountKey, pagingKey) } - return PagingResult( endOfPaginationReached = tweet.isEmpty(), - data = data, + data = tweet.mapNotNull { it.render(accountKey) }, nextKey = instructions.cursor(), ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/UserTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/UserTimelineRemoteMediator.kt index 24ca48625..5e16adc23 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/UserTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/UserTimelineRemoteMediator.kt @@ -2,16 +2,15 @@ package dev.dimension.flare.data.datasource.xqt import androidx.paging.ExperimentalPagingApi import dev.dimension.flare.common.encodeJson -import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.mapper.cursor -import dev.dimension.flare.data.database.cache.mapper.toDbPagingTimeline import dev.dimension.flare.data.database.cache.mapper.tweets -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineRemoteMediator +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.xqt.XQTService import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.mapper.render import kotlinx.serialization.Required import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -20,28 +19,24 @@ import kotlinx.serialization.Serializable internal class UserTimelineRemoteMediator( private val userKey: MicroBlogKey, private val service: XQTService, - private val database: CacheDatabase, private val accountKey: MicroBlogKey, -) : BaseTimelineRemoteMediator( - database = database, - ) { - override val pagingKey = "user_timeline_${userKey}_$accountKey" +) : CacheableRemoteLoader { + override val pagingKey: String = "user_timeline_${userKey}_$accountKey" - override suspend fun timeline( + override suspend fun load( pageSize: Int, request: PagingRequest, - ): PagingResult { + ): PagingResult { val response = when (request) { PagingRequest.Refresh -> { - service - .getUserTweets( - variables = - UserTimelineRequest( - userID = userKey.id, - count = pageSize.toLong(), - ).encodeJson(), - ) + service.getUserTweets( + variables = + UserTimelineRequest( + userID = userKey.id, + count = pageSize.toLong(), + ).encodeJson(), + ) } is PagingRequest.Prepend -> { @@ -75,11 +70,9 @@ internal class UserTimelineRemoteMediator( includePin = request is PagingRequest.Refresh, ) - val data = tweet.mapNotNull { it.toDbPagingTimeline(accountKey, pagingKey) } - return PagingResult( endOfPaginationReached = tweet.isEmpty(), - data = data, + data = tweet.mapNotNull { it.render(accountKey) }, nextKey = instructions.cursor(), ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDataSource.kt index 8bf7b76ad..a8168ef01 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDataSource.kt @@ -11,36 +11,36 @@ import dev.dimension.flare.common.FileType import dev.dimension.flare.common.InAppNotification import dev.dimension.flare.common.MemCacheable import dev.dimension.flare.common.decodeJson -import dev.dimension.flare.common.encodeJson import dev.dimension.flare.data.database.cache.CacheDatabase -import dev.dimension.flare.data.database.cache.connect import dev.dimension.flare.data.database.cache.mapper.XQT -import dev.dimension.flare.data.database.cache.mapper.toDbUser -import dev.dimension.flare.data.database.cache.mapper.tweets import dev.dimension.flare.data.database.cache.model.DbMessageItem -import dev.dimension.flare.data.database.cache.model.DbPagingTimeline import dev.dimension.flare.data.database.cache.model.MessageContent -import dev.dimension.flare.data.database.cache.model.StatusContent -import dev.dimension.flare.data.database.cache.model.updateStatusUseCase import dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataSource import dev.dimension.flare.data.datasource.microblog.ComposeConfig import dev.dimension.flare.data.datasource.microblog.ComposeData import dev.dimension.flare.data.datasource.microblog.ComposeProgress import dev.dimension.flare.data.datasource.microblog.ComposeType +import dev.dimension.flare.data.datasource.microblog.DatabaseUpdater import dev.dimension.flare.data.datasource.microblog.DirectMessageDataSource import dev.dimension.flare.data.datasource.microblog.NotificationFilter -import dev.dimension.flare.data.datasource.microblog.ProfileAction +import dev.dimension.flare.data.datasource.microblog.PostEvent import dev.dimension.flare.data.datasource.microblog.ProfileTab -import dev.dimension.flare.data.datasource.microblog.RelationDataSource -import dev.dimension.flare.data.datasource.microblog.StatusEvent import dev.dimension.flare.data.datasource.microblog.createSendingDirectMessage -import dev.dimension.flare.data.datasource.microblog.list.ListDataSource -import dev.dimension.flare.data.datasource.microblog.list.ListHandler -import dev.dimension.flare.data.datasource.microblog.list.ListMemberHandler -import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineLoader +import dev.dimension.flare.data.datasource.microblog.datasource.ListDataSource +import dev.dimension.flare.data.datasource.microblog.datasource.NotificationDataSource +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.UserDataSource +import dev.dimension.flare.data.datasource.microblog.handler.ListHandler +import dev.dimension.flare.data.datasource.microblog.handler.ListMemberHandler +import dev.dimension.flare.data.datasource.microblog.handler.NotificationHandler +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.paging.RemoteLoader +import dev.dimension.flare.data.datasource.microblog.paging.toPagingSource import dev.dimension.flare.data.datasource.microblog.pagingConfig -import dev.dimension.flare.data.datasource.microblog.relationKeyWithUserKey -import dev.dimension.flare.data.datasource.microblog.timelinePager import dev.dimension.flare.data.network.xqt.XQTService import dev.dimension.flare.data.network.xqt.model.AddToConversationRequest import dev.dimension.flare.data.network.xqt.model.CreateBookmarkRequest @@ -58,32 +58,23 @@ import dev.dimension.flare.data.network.xqt.model.PostCreateTweetRequestVariable import dev.dimension.flare.data.network.xqt.model.PostCreateTweetRequestVariablesReply import dev.dimension.flare.data.network.xqt.model.PostDeleteRetweetRequest import dev.dimension.flare.data.network.xqt.model.PostDeleteRetweetRequestVariables -import dev.dimension.flare.data.network.xqt.model.PostDeleteTweetRequest import dev.dimension.flare.data.network.xqt.model.PostDmNew2Request import dev.dimension.flare.data.network.xqt.model.PostFavoriteTweetRequest import dev.dimension.flare.data.network.xqt.model.PostMediaMetadataCreateRequest import dev.dimension.flare.data.network.xqt.model.PostUnfavoriteTweetRequest -import dev.dimension.flare.data.network.xqt.model.User -import dev.dimension.flare.data.network.xqt.model.UserUnavailable import dev.dimension.flare.data.repository.AccountRepository import dev.dimension.flare.data.repository.LocalFilterRepository import dev.dimension.flare.data.repository.tryRun import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.model.PlatformType import dev.dimension.flare.shared.image.ImageCompressor import dev.dimension.flare.ui.model.UiAccount import dev.dimension.flare.ui.model.UiDMItem import dev.dimension.flare.ui.model.UiDMRoom -import dev.dimension.flare.ui.model.UiHashtag import dev.dimension.flare.ui.model.UiPodcast -import dev.dimension.flare.ui.model.UiProfile -import dev.dimension.flare.ui.model.UiRelation import dev.dimension.flare.ui.model.UiState -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.model.mapper.render -import dev.dimension.flare.ui.model.mapper.toUi -import dev.dimension.flare.ui.model.toUi import dev.dimension.flare.ui.presenter.compose.ComposeStatus import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -96,7 +87,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull @@ -115,11 +105,14 @@ private const val MAX_ASYNC_UPLOAD_SIZE = 10 internal class XQTDataSource( override val accountKey: MicroBlogKey, ) : AuthenticatedMicroblogDataSource, + NotificationDataSource, + UserDataSource, + PostDataSource, KoinComponent, - StatusEvent.XQT, ListDataSource, DirectMessageDataSource, - RelationDataSource { + RelationDataSource, + PostEventHandler.Handler { private val database: CacheDatabase by inject() private val localFilterRepository: LocalFilterRepository by inject() private val coroutineScope: CoroutineScope by inject() @@ -139,11 +132,129 @@ internal class XQTDataSource( .map { it.chocolate }, ) } + private val loader by lazy { + XQTLoader( + accountKey = accountKey, + service = service, + ) + } private val listLoader = XQTListLoader(service, accountKey) private val listMemberLoader = XQTListMemberLoader(service, accountKey) + override val notificationHandler by lazy { + NotificationHandler( + accountKey = accountKey, + loader = loader, + ) + } + + 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 relationHandler by lazy { + RelationHandler( + accountType = AccountType.Specific(accountKey), + dataSource = loader, + ) + } + + override val supportedRelationTypes: Set + get() = loader.supportedTypes + + override val postEventHandler by lazy { + PostEventHandler( + accountType = AccountType.Specific(accountKey), + handler = this, + ) + } + + override suspend fun handle( + event: PostEvent, + updater: DatabaseUpdater, + ) { + require(event is PostEvent.XQT) + when (event) { + is PostEvent.XQT.Retweet -> { + if (event.retweeted) { + service.postDeleteRetweet( + postDeleteRetweetRequest = + PostDeleteRetweetRequest( + variables = PostDeleteRetweetRequestVariables(sourceTweetId = event.postKey.id), + ), + ) + } else { + service.postCreateRetweet( + postCreateRetweetRequest = + PostCreateRetweetRequest( + variables = + PostCreateRetweetRequestVariables( + tweetId = event.postKey.id, + ), + ), + ) + } + } + + is PostEvent.XQT.Like -> { + if (event.liked) { + service.postUnfavoriteTweet( + postUnfavoriteTweetRequest = + PostUnfavoriteTweetRequest( + variables = PostCreateRetweetRequestVariables(tweetId = event.postKey.id), + ), + ) + } else { + service.postFavoriteTweet( + postFavoriteTweetRequest = + PostFavoriteTweetRequest( + variables = + PostCreateRetweetRequestVariables( + tweetId = event.postKey.id, + ), + ), + ) + } + } + + is PostEvent.XQT.Bookmark -> { + if (event.bookmarked) { + service.postDeleteBookmark( + postDeleteBookmarkRequest = + DeleteBookmarkRequest( + variables = + DeleteBookmarkRequestVariables( + tweetId = event.postKey.id, + ), + ), + ) + } else { + service.postCreateBookmark( + postCreateBookmarkRequest = + CreateBookmarkRequest( + variables = + CreateBookmarkRequestVariables( + tweetId = event.postKey.id, + ), + ), + ) + } + } + } + } + override val listHandler = ListHandler( pagingKey = "list_$accountKey", @@ -161,7 +272,6 @@ internal class XQTDataSource( override fun homeTimeline() = HomeTimelineRemoteMediator( service, - database, accountKey, inAppNotification, ) @@ -170,20 +280,16 @@ internal class XQTDataSource( pageSize: Int = 20, pagingKey: String = "featured_$accountKey", scope: CoroutineScope, - ): Flow> = - timelinePager( - pageSize = pageSize, - database = database, - scope = scope, - filterFlow = localFilterRepository.getFlow(forTimeline = true), - accountRepository = accountRepository, - mediator = featuredTimelineLoader(), - ) + ): Flow> = + Pager( + config = pagingConfig, + ) { + featuredTimelineLoader().toPagingSource() + }.flow.cachedIn(scope) fun featuredTimelineLoader() = FeaturedTimelineRemoteMediator( service, - database, accountKey, ) @@ -191,152 +297,45 @@ internal class XQTDataSource( pageSize: Int = 20, pagingKey: String = "bookmark_$accountKey", scope: CoroutineScope, - ): Flow> = - timelinePager( - pageSize = pageSize, - database = database, - scope = scope, - filterFlow = localFilterRepository.getFlow(forTimeline = true), - accountRepository = accountRepository, - mediator = bookmarkTimelineLoader(), - ) + ): Flow> = + Pager( + config = pagingConfig, + ) { + bookmarkTimelineLoader().toPagingSource() + }.flow.cachedIn(scope) fun bookmarkTimelineLoader() = BookmarkTimelineRemoteMediator( service, - database, accountKey, ) fun deviceFollowTimelineLoader() = DeviceFollowRemoteMediator( service, - database, accountKey, ) - override fun notification( - type: NotificationFilter, - pageSize: Int, - scope: CoroutineScope, - ): Flow> { + override fun notification(type: NotificationFilter): RemoteLoader = if (type == NotificationFilter.All) { - return Pager( - config = pagingConfig, - ) { - NotificationPagingSource( - locale = "en", - service = service, - accountKey = accountKey, - event = this, - onClearMarker = { - MemCacheable.update(notificationMarkerKey, 0) - }, - ) - }.flow.cachedIn(scope) + NotificationPagingSource( + locale = "en", + service = service, + accountKey = accountKey, + onClearMarker = { + notificationHandler.clear() + }, + ) } else { - return timelinePager( - pageSize = pageSize, - database = database, - scope = scope, - filterFlow = localFilterRepository.getFlow(forNotification = true), - accountRepository = accountRepository, - mediator = - MentionRemoteMediator( - service, - database, - accountKey, - ), + MentionRemoteMediator( + service, + accountKey, ) } - } override val supportedNotificationFilter: List get() = listOf(NotificationFilter.All, NotificationFilter.Mention) - override fun userByAcct(acct: String): CacheData { - val (name, host) = MicroBlogKey.valueOf(acct.removePrefix("@")) - return Cacheable( - fetchSource = { - val user = - service - .userByScreenName(name) - .body() - ?.data - ?.user - ?.result - ?.let { - when (it) { - is User -> it - is UserUnavailable -> null - } - }?.toDbUser(accountKey) ?: throw Exception("User not found") - database.userDao().insert(user) - }, - cacheSource = { - database - .userDao() - .findByHandleAndHost(name, host, PlatformType.xQt) - .distinctUntilChanged() - .mapNotNull { it?.render(accountKey) } - }, - ) - } - - override fun userById(id: String): CacheData { - val userKey = MicroBlogKey(id, accountKey.host) - return Cacheable( - fetchSource = { - val user = - service - .userById(id) - .body() - ?.data - ?.user - ?.result - ?.let { - when (it) { - is User -> it - is UserUnavailable -> null - } - }?.toDbUser(accountKey) ?: throw Exception("User not found") - database.userDao().insert(user) - }, - cacheSource = { - database - .userDao() - .findByKey(userKey) - .distinctUntilChanged() - .mapNotNull { it?.render(accountKey) } - }, - ) - } - - override fun relation(userKey: MicroBlogKey): Flow> = - MemCacheable( - relationKeyWithUserKey(userKey), - ) { - val userResponse = - service - .userById(userKey.id) - .body() - ?.data - ?.user - ?.result - ?.let { - when (it) { - is User -> it - is UserUnavailable -> null - } - } ?: throw Exception("User not found") - val user = userResponse.toDbUser(accountKey) - - service - .profileSpotlights(user.handle) - .body() - ?.toUi(muting = userResponse.legacy.muting) ?: throw Exception("User not found") - }.toUi() - override fun userTimeline( userKey: MicroBlogKey, mediaOnly: Boolean, @@ -344,14 +343,12 @@ internal class XQTDataSource( UserMediaTimelineRemoteMediator( userKey, service, - database, accountKey, ) } else { UserTimelineRemoteMediator( userKey, service, - database, accountKey, ) } @@ -360,72 +357,10 @@ internal class XQTDataSource( StatusDetailRemoteMediator( statusKey = statusKey, service = service, - database = database, accountKey = accountKey, statusOnly = false, - event = this, ) - override fun status(statusKey: MicroBlogKey): CacheData { - val pagingKey = "status_only_$statusKey" - return Cacheable( - fetchSource = { - if (!database.pagingTimelineDao().existsPaging(accountKey, pagingKey)) { - database.statusDao().get(statusKey, AccountType.Specific(accountKey)).firstOrNull()?.let { - database.connect { - database - .pagingTimelineDao() - .insertAll( - listOf( - DbPagingTimeline( - accountType = AccountType.Specific(accountKey), - statusKey = statusKey, - pagingKey = pagingKey, - sortId = 0, - ), - ), - ) - } - } - } - val response = - service - .getTweetDetail( - variables = - TweetDetailRequest( - focalTweetID = statusKey.id, - cursor = null, - ).encodeJson(), - ).body() - ?.data - ?.threadedConversationWithInjectionsV2 - ?.instructions - .orEmpty() - val tweet = response.tweets() - val item = tweet.firstOrNull { it.id == statusKey.id } - if (item != null) { - XQT.save( - accountKey = accountKey, - pagingKey = pagingKey, - database = database, - tweet = listOf(item), - ) - } else { - throw Exception("Status not found") - } - }, - cacheSource = { - database - .pagingTimelineDao() - .get(pagingKey, accountType = AccountType.Specific(accountKey)) - .distinctUntilChanged() - .mapNotNull { - it?.render(this) - } - }, - ) - } - override suspend fun compose( data: ComposeData, progress: (ComposeProgress) -> Unit, @@ -450,11 +385,10 @@ internal class XQTDataSource( ?.let { it as? ComposeStatus.Quote }?.let { - data.referenceStatus.data?.content as? UiTimeline.ItemContent.Status + data.referenceStatus.data as? UiTimelineV2.Post }?.user ?.handle - ?.removePrefix("@") - ?.removeSuffix("@${accountKey.host}") + ?.normalizedRaw val maxProgress = data.medias.size + 1 val mediaIds = data.medias.mapIndexed { index, (item, altText) -> @@ -611,77 +545,35 @@ internal class XQTDataSource( mediaIdString } - override suspend fun deleteStatus(statusKey: MicroBlogKey) { - tryRun { - service.postDeleteTweet( - postDeleteTweetRequest = - PostDeleteTweetRequest( - variables = - PostCreateRetweetRequestVariables( - tweetId = statusKey.id, - ), - ), - ) - // delete status from cache - database.connect { - database.statusDao().delete( - statusKey = statusKey, - accountType = AccountType.Specific(accountKey), - ) - database.statusReferenceDao().delete(statusKey) - database.pagingTimelineDao().deleteStatus( - accountKey = accountKey, - statusKey = statusKey, - ) - } - } - } - override fun searchStatus(query: String) = SearchStatusPagingSource( service, - database, accountKey, query, ) - override fun searchUser( - query: String, - pageSize: Int, - ): Flow> = - Pager( - config = pagingConfig, - ) { - SearchUserPagingSource( - service = service, - accountKey = accountKey, - query = query, - ) - }.flow + override fun searchUser(query: String) = + SearchUserPagingSource( + service = service, + accountKey = accountKey, + query = query, + ) - override fun discoverUsers(pageSize: Int): Flow> = - Pager( - config = pagingConfig, - ) { - TrendsUserPagingSource( - service, - accountKey, - ) - }.flow + override fun discoverUsers() = + TrendsUserPagingSource( + service, + accountKey, + ) - override fun discoverStatuses(): BaseTimelineLoader { + override fun discoverStatuses(): RemoteLoader { // not supported throw UnsupportedOperationException("XQT does not support discover statuses") } - override fun discoverHashtags(pageSize: Int): Flow> = - Pager( - config = pagingConfig, - ) { - TrendHashtagPagingSource( - service, - ) - }.flow + override fun discoverHashtags() = + TrendHashtagPagingSource( + service, + ) override fun composeConfig(type: ComposeType): ComposeConfig = ComposeConfig( @@ -695,481 +587,20 @@ internal class XQTDataSource( ), ) - override suspend fun follow( - userKey: MicroBlogKey, - relation: UiRelation, - ) { - when { - relation.following -> unfollow(userKey) - relation.blocking -> unblock(userKey) - else -> follow(userKey) - } - } - - override fun profileActions(): List = - listOf( - object : ProfileAction.Mute { - override suspend fun invoke( - userKey: MicroBlogKey, - relation: UiRelation, - ) { - if (relation.muted) { - unmute(userKey) - } else { - mute(userKey) - } - } - - override fun relationState(relation: UiRelation): Boolean = relation.muted - }, - object : ProfileAction.Block { - override suspend fun invoke( - userKey: MicroBlogKey, - relation: UiRelation, - ) { - if (relation.blocking) { - unblock(userKey) - } else { - block(userKey) - } - } - - override fun relationState(relation: UiRelation): Boolean = relation.blocking - }, + override fun following(userKey: MicroBlogKey) = + FollowingPagingSource( + service = service, + userKey = userKey, + accountKey = accountKey, ) - override fun like( - statusKey: MicroBlogKey, - liked: Boolean, - ) { - coroutineScope.launch { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - legacy = - it.data.legacy?.copy( - favorited = !liked, - favoriteCount = - if (liked) { - it.data.legacy.favoriteCount - .minus(1) - } else { - it.data.legacy.favoriteCount - .plus(1) - }, - ), - ), - ) - }, - ) - - tryRun { - if (liked) { - service.postUnfavoriteTweet( - postUnfavoriteTweetRequest = - PostUnfavoriteTweetRequest( - variables = PostCreateRetweetRequestVariables(tweetId = statusKey.id), - ), - ) - } else { - service.postFavoriteTweet( - postFavoriteTweetRequest = - PostFavoriteTweetRequest( - variables = - PostCreateRetweetRequestVariables( - tweetId = statusKey.id, - ), - ), - ) - } - }.onFailure { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - legacy = - it.data.legacy?.copy( - favorited = liked, - favoriteCount = - if (liked) { - it.data.legacy.favoriteCount - .plus(1) - } else { - it.data.legacy.favoriteCount - .minus(1) - }, - ), - ), - ) - }, - ) - }.onSuccess { -// updateStatusUseCase( -// statusKey = status.statusKey, -// accountKey = status.accountKey, -// cacheDatabase = database, -// update = { -// it.copy( -// data = result, -// ) -// }, -// ) - } - } - } - - override fun retweet( - statusKey: MicroBlogKey, - retweeted: Boolean, - ) { - coroutineScope.launch { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - legacy = - it.data.legacy?.copy( - retweeted = !retweeted, - retweetCount = - if (retweeted) { - it.data.legacy.retweetCount - .minus(1) - } else { - it.data.legacy.retweetCount - .plus(1) - }, - ), - ), - ) - }, - ) - - tryRun { - if (retweeted) { - service.postDeleteRetweet( - postDeleteRetweetRequest = - PostDeleteRetweetRequest( - variables = PostDeleteRetweetRequestVariables(sourceTweetId = statusKey.id), - ), - ) - } else { - service.postCreateRetweet( - postCreateRetweetRequest = - PostCreateRetweetRequest( - variables = - PostCreateRetweetRequestVariables( - tweetId = statusKey.id, - ), - ), - ) - } - }.onFailure { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - legacy = - it.data.legacy?.copy( - retweeted = retweeted, - retweetCount = - if (retweeted) { - it.data.legacy.retweetCount - .plus(1) - } else { - it.data.legacy.retweetCount - .minus(1) - }, - ), - ), - ) - }, - ) - }.onSuccess { -// updateStatusUseCase( -// statusKey = status.statusKey, -// accountKey = status.accountKey, -// cacheDatabase = database, -// update = { -// it.copy( -// data = result, -// ) -// }, -// ) - } - } - } - - override fun bookmark( - statusKey: MicroBlogKey, - bookmarked: Boolean, - ) { - coroutineScope.launch { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - legacy = - it.data.legacy?.copy( - bookmarked = !bookmarked, - bookmarkCount = - if (bookmarked) { - maxOf(0, (it.data.legacy.bookmarkCount ?: 1) - 1) - } else { - (it.data.legacy.bookmarkCount ?: 0) + 1 - }, - ), - ), - ) - }, - ) - - tryRun { - if (bookmarked) { - service.postDeleteBookmark( - postDeleteBookmarkRequest = - DeleteBookmarkRequest( - variables = - DeleteBookmarkRequestVariables( - tweetId = statusKey.id, - ), - ), - ) - } else { - service.postCreateBookmark( - postCreateBookmarkRequest = - CreateBookmarkRequest( - variables = - CreateBookmarkRequestVariables( - tweetId = statusKey.id, - ), - ), - ) - } - }.onFailure { - updateStatusUseCase( - statusKey = statusKey, - accountKey = accountKey, - cacheDatabase = database, - update = { - it.copy( - data = - it.data.copy( - legacy = - it.data.legacy?.copy( - bookmarked = bookmarked, - bookmarkCount = - if (bookmarked) { - maxOf( - 0, - (it.data.legacy.bookmarkCount ?: 1) - 1, - ) - } else { - (it.data.legacy.bookmarkCount ?: 0) + 1 - }, - ), - ), - ) - }, - ) - }.onSuccess { - } - } - } - - suspend fun follow(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - following = true, - ) - } - tryRun { - service.postCreateFriendships(userId = userKey.id) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - following = false, - ) - } - } - } - - suspend fun unfollow(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - following = false, - ) - } - tryRun { - service.postDestroyFriendships(userId = userKey.id) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - following = true, - ) - } - } - } - - override suspend fun mute(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - muted = true, - ) - } - tryRun { - service.postMutesUsersCreate(userKey.id) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - muted = false, - ) - } - } - } - - suspend fun unmute(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - muted = false, - ) - } - tryRun { - service.postMutesUsersDestroy(userKey.id) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - muted = true, - ) - } - } - } - - override suspend fun block(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - blocking = true, - ) - } - tryRun { - service.postBlocksCreate(userKey.id) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - blocking = false, - ) - } - } - } - - suspend fun unblock(userKey: MicroBlogKey) { - val key = relationKeyWithUserKey(userKey) - MemCacheable.updateWith( - key = key, - ) { - it.copy( - blocking = false, - ) - } - tryRun { - service.postBlocksDestroy(userKey.id) - }.onFailure { - MemCacheable.updateWith( - key = key, - ) { - it.copy( - blocking = true, - ) - } - } - } - - private val notificationMarkerKey: String - get() = "notificationBadgeCount_$accountKey" - - override fun notificationBadgeCount(): CacheData = - MemCacheable( - key = notificationMarkerKey, - fetchSource = { - service.getBadgeCount().ntabUnreadCount?.toInt() ?: 0 - }, + override fun fans(userKey: MicroBlogKey) = + FansPagingSource( + service = service, + userKey = userKey, + accountKey = accountKey, ) - override fun following( - userKey: MicroBlogKey, - scope: CoroutineScope, - pageSize: Int, - ): Flow> = - Pager( - config = pagingConfig, - ) { - FollowingPagingSource( - service = service, - userKey = userKey, - accountKey = accountKey, - ) - }.flow.cachedIn(scope) - - override fun fans( - userKey: MicroBlogKey, - scope: CoroutineScope, - pageSize: Int, - ): Flow> = - Pager( - config = pagingConfig, - ) { - FansPagingSource( - service = service, - userKey = userKey, - accountKey = accountKey, - ) - }.flow.cachedIn(scope) - override fun profileTabs(userKey: MicroBlogKey): ImmutableList = listOfNotNull( ProfileTab.Timeline( @@ -1182,7 +613,6 @@ internal class XQTDataSource( UserRepliesTimelineRemoteMediator( service = service, accountKey = accountKey, - database = database, userKey = userKey, ), ), @@ -1194,7 +624,6 @@ internal class XQTDataSource( UserLikesTimelineRemoteMediator( service = service, accountKey = accountKey, - database = database, userKey = userKey, ), ) @@ -1207,7 +636,6 @@ internal class XQTDataSource( ListTimelineRemoteMediator( listId, service, - database, accountKey, ) @@ -1229,7 +657,7 @@ internal class XQTDataSource( .cachedIn(scope) .combine(credentialFlow) { paging, credential -> paging.map { - it.render(accountKey = accountKey, credential = credential, statusEvent = this) + it.render(accountKey = accountKey, credential = credential) } }.cachedIn(scope) @@ -1256,7 +684,7 @@ internal class XQTDataSource( .cachedIn(scope) .combine(credentialFlow) { paging, credential -> paging.map { - it.render(accountKey = accountKey, credential = credential, statusEvent = this) + it.render(accountKey = accountKey, credential = credential) } }.cachedIn(scope) @@ -1387,7 +815,6 @@ internal class XQTDataSource( room?.render( accountKey = accountKey, credential = credential, - statusEvent = this, ) }.mapNotNull { it } }, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTListLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTListLoader.kt index fe6b95286..a4464589d 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTListLoader.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTListLoader.kt @@ -1,9 +1,9 @@ package dev.dimension.flare.data.datasource.xqt import dev.dimension.flare.data.database.cache.mapper.cursor -import dev.dimension.flare.data.datasource.microblog.list.ListLoader import dev.dimension.flare.data.datasource.microblog.list.ListMetaData import dev.dimension.flare.data.datasource.microblog.list.ListMetaDataType +import dev.dimension.flare.data.datasource.microblog.loader.ListLoader import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.xqt.XQTService diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTListMemberLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTListMemberLoader.kt index f35b3eb9a..8f405a324 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTListMemberLoader.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTListMemberLoader.kt @@ -1,10 +1,8 @@ package dev.dimension.flare.data.datasource.xqt import dev.dimension.flare.data.database.cache.mapper.cursor -import dev.dimension.flare.data.database.cache.mapper.toDbUser import dev.dimension.flare.data.database.cache.mapper.users -import dev.dimension.flare.data.database.cache.model.DbUser -import dev.dimension.flare.data.datasource.microblog.list.ListMemberLoader +import dev.dimension.flare.data.datasource.microblog.loader.ListMemberLoader import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.xqt.XQTService @@ -14,6 +12,7 @@ import dev.dimension.flare.data.network.xqt.model.User import dev.dimension.flare.data.network.xqt.model.UserUnavailable import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiList +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.mapper.render internal class XQTListMemberLoader( @@ -24,7 +23,7 @@ internal class XQTListMemberLoader( pageSize: Int, request: PagingRequest, listId: String, - ): PagingResult { + ): PagingResult { val cursor = (request as? PagingRequest.Append)?.nextKey val response = service @@ -47,8 +46,8 @@ internal class XQTListMemberLoader( val nextCursor = response?.cursor() val result = - response?.users().orEmpty().map { - it.toDbUser(accountKey = accountKey) + response?.users().orEmpty().mapNotNull { + it.render(accountKey = accountKey) } return PagingResult( @@ -60,7 +59,7 @@ internal class XQTListMemberLoader( override suspend fun addMember( listId: String, userKey: MicroBlogKey, - ): DbUser { + ): UiProfile { service.addMember( request = AddMemberRequest( @@ -82,7 +81,7 @@ internal class XQTListMemberLoader( is User -> it is UserUnavailable -> null } - }?.toDbUser(accountKey) + }?.render(accountKey) ?: throw Exception("User not found") } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTLoader.kt new file mode 100644 index 000000000..c14c6b7d3 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTLoader.kt @@ -0,0 +1,147 @@ +package dev.dimension.flare.data.datasource.xqt + +import dev.dimension.flare.common.encodeJson +import dev.dimension.flare.data.database.cache.mapper.tweets +import dev.dimension.flare.data.datasource.microblog.loader.NotificationLoader +import dev.dimension.flare.data.datasource.microblog.loader.PostLoader +import dev.dimension.flare.data.datasource.microblog.loader.RelationActionType +import dev.dimension.flare.data.datasource.microblog.loader.RelationLoader +import dev.dimension.flare.data.datasource.microblog.loader.UserLoader +import dev.dimension.flare.data.network.xqt.XQTService +import dev.dimension.flare.data.network.xqt.model.PostCreateRetweetRequestVariables +import dev.dimension.flare.data.network.xqt.model.PostDeleteTweetRequest +import dev.dimension.flare.data.network.xqt.model.User +import dev.dimension.flare.data.network.xqt.model.UserUnavailable +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 dev.dimension.flare.ui.model.mapper.render +import dev.dimension.flare.ui.model.mapper.screenName +import dev.dimension.flare.ui.model.mapper.toUi + +internal class XQTLoader( + val accountKey: MicroBlogKey, + private val service: XQTService, +) : NotificationLoader, + UserLoader, + PostLoader, + RelationLoader { + override val supportedTypes: Set = + setOf( + RelationActionType.Follow, + RelationActionType.Block, + RelationActionType.Mute, + ) + + override suspend fun notificationBadgeCount(): Int = service.getBadgeCount().ntabUnreadCount?.toInt() ?: 0 + + override suspend fun userByHandleAndHost(uiHandle: UiHandle): UiProfile { + val user = + service + .userByScreenName(uiHandle.normalizedRaw) + .body() + ?.data + ?.user + ?.result + ?.let { + when (it) { + is User -> it + is UserUnavailable -> null + } + } ?: throw Exception("User not found") + return user.render(accountKey) + } + + override suspend fun userById(id: String): UiProfile { + val user = + service + .userById(id) + .body() + ?.data + ?.user + ?.result + ?.let { + when (it) { + is User -> it + is UserUnavailable -> null + } + } ?: throw Exception("User not found") + return user.render(accountKey) + } + + override suspend fun status(statusKey: MicroBlogKey): UiTimelineV2 { + val instructions = + service + .getTweetDetail( + variables = + TweetDetailRequest( + focalTweetID = statusKey.id, + cursor = null, + ).encodeJson(), + ).body() + ?.data + ?.threadedConversationWithInjectionsV2 + ?.instructions + .orEmpty() + val item = instructions.tweets().firstOrNull { it.id == statusKey.id } + return item?.render(accountKey) ?: throw Exception("Status not found") + } + + override suspend fun deleteStatus(statusKey: MicroBlogKey) { + service.postDeleteTweet( + postDeleteTweetRequest = + PostDeleteTweetRequest( + variables = + PostCreateRetweetRequestVariables( + tweetId = statusKey.id, + ), + ), + ) + } + + override suspend fun relation(userKey: MicroBlogKey): UiRelation { + val userResponse = + service + .userById(userKey.id) + .body() + ?.data + ?.user + ?.result + ?.let { + when (it) { + is User -> it + is UserUnavailable -> null + } + } ?: throw Exception("User not found") + return service + .profileSpotlights(userResponse.screenName) + .body() + ?.toUi(muting = userResponse.legacy.muting) ?: throw Exception("User not found") + } + + override suspend fun follow(userKey: MicroBlogKey) { + service.postCreateFriendships(userId = userKey.id) + } + + override suspend fun unfollow(userKey: MicroBlogKey) { + service.postDestroyFriendships(userId = userKey.id) + } + + override suspend fun block(userKey: MicroBlogKey) { + service.postBlocksCreate(userKey.id) + } + + override suspend fun unblock(userKey: MicroBlogKey) { + service.postBlocksDestroy(userKey.id) + } + + override suspend fun mute(userKey: MicroBlogKey) { + service.postMutesUsersCreate(userKey.id) + } + + override suspend fun unmute(userKey: MicroBlogKey) { + service.postMutesUsersDestroy(userKey.id) + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/ComposeConfigData.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/ComposeConfigData.kt index 084e04bc1..c70088b46 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/ComposeConfigData.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/ComposeConfigData.kt @@ -1,7 +1,7 @@ package dev.dimension.flare.data.datastore.model import androidx.datastore.core.okio.OkioSerializer -import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.UiTimelineV2 import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromByteArray @@ -12,8 +12,8 @@ import okio.BufferedSource @Serializable internal data class ComposeConfigData( - val visibility: UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type = - UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Public, + val visibility: UiTimelineV2.Post.Visibility = + UiTimelineV2.Post.Visibility.Public, ) @OptIn(ExperimentalSerializationApi::class) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/vvo/model/TimelineData.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/vvo/model/TimelineData.kt index 095c67ab6..121c6ab5b 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/vvo/model/TimelineData.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/vvo/model/TimelineData.kt @@ -515,7 +515,7 @@ internal object VVODateSerializer : KSerializer { }, ) }.getOrElse { - Instant.parse(str) + Instant.parseOrNull(str) ?: Instant.DISTANT_PAST } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/AccountRepository.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/AccountRepository.kt index 617042f90..732165dc5 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/AccountRepository.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/AccountRepository.kt @@ -31,7 +31,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull @@ -169,7 +168,6 @@ internal fun accountProvider( key1 = accountType, ) { when (accountType) { - AccountType.Active -> repository.activeAccount AccountType.Guest -> flowOf( UiState.Error( @@ -203,31 +201,6 @@ internal fun accountServiceFlow( repository: AccountRepository, ): Flow = when (accountType) { - AccountType.Active -> { - repository - .activeAccount - .map { it.takeSuccess() } - .distinctUntilChangedBy { it?.accountKey } - .flatMapLatest { - if (it != null) { - flowOf(it.dataSource) - } else { - val guestData = repository.appDataStore.guestDataStore.data - guestData.map { - when (it.platformType) { - PlatformType.Mastodon -> - GuestMastodonDataSource( - host = it.host, - locale = Locale.language, - ) - - else -> throw UnsupportedOperationException() - } - } - } - } - } - AccountType.Guest -> { val guestData = repository.appDataStore.guestDataStore.data guestData.map { diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/model/AccountType.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/model/AccountType.kt index 85beb33ea..cb7ecbcab 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/model/AccountType.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/model/AccountType.kt @@ -3,6 +3,7 @@ package dev.dimension.flare.model import androidx.compose.runtime.Immutable import kotlinx.serialization.Serializable +@Immutable @Serializable internal sealed interface DbAccountType @@ -18,15 +19,16 @@ public sealed class AccountType { override fun toString(): String = "specific_$accountKey" } - @Serializable - @Immutable - public data object Active : AccountType() { - override fun toString(): String = "active" - } - @Serializable @Immutable public data object Guest : AccountType(), DbAccountType { override fun toString(): String = "guest" } } + +internal fun MicroBlogKey?.toAccountType(): AccountType = + if (this == null) { + AccountType.Guest + } else { + AccountType.Specific(this) + } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/ClickContext.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/ClickContext.kt index 807475aa9..e72b2e851 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/ClickContext.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/ClickContext.kt @@ -1,5 +1,9 @@ package dev.dimension.flare.ui.model +import dev.dimension.flare.data.datasource.microblog.PostEvent +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.route.DeeplinkRoute +import dev.dimension.flare.ui.route.toUri import kotlinx.serialization.Serializable public data class ClickContext( @@ -12,9 +16,42 @@ internal sealed interface ClickEvent { data object Noop : ClickEvent @Serializable - data class Deeplink( + data class Deeplink private constructor( val url: String, - ) : ClickEvent + ) : ClickEvent { + constructor(route: DeeplinkRoute) : this(route.toUri()) + constructor(route: DeeplinkEvent) : this(route.toUri()) + } + + companion object { + fun event( + accountKey: MicroBlogKey?, + postEvent: PostEvent, + ) = if (accountKey == null) { + Noop + } else { + Deeplink( + DeeplinkEvent( + accountKey = accountKey, + postEvent = postEvent, + ), + ) + } + + inline fun event( + accountKey: MicroBlogKey?, + eventCreator: (accountKey: MicroBlogKey) -> PostEvent, + ) = if (accountKey == null) { + Noop + } else { + Deeplink( + DeeplinkEvent( + accountKey = accountKey, + postEvent = eventCreator.invoke(accountKey), + ), + ) + } + } } internal val ClickEvent.onClicked: ClickContext.() -> Unit diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/DeeplinkEvent.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/DeeplinkEvent.kt new file mode 100644 index 000000000..e187ca441 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/DeeplinkEvent.kt @@ -0,0 +1,29 @@ +package dev.dimension.flare.ui.model + +import dev.dimension.flare.data.datasource.microblog.PostEvent +import dev.dimension.flare.model.MicroBlogKey +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromHexString +import kotlinx.serialization.encodeToHexString +import kotlinx.serialization.protobuf.ProtoBuf + +@Serializable +@OptIn(ExperimentalSerializationApi::class) +internal data class DeeplinkEvent( + val accountKey: MicroBlogKey, + val postEvent: PostEvent, +) { + companion object { + const val SCHEME = "flare-event" + + fun parse(uri: String): DeeplinkEvent? = + runCatching { + ProtoBuf.decodeFromHexString(uri.removePrefix("$SCHEME://")) + }.getOrNull() + + fun isDeeplinkEvent(uri: String): Boolean = uri.startsWith("$SCHEME://") + } + + fun toUri(): String = "$SCHEME://${ProtoBuf.encodeToHexString(this)}" +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiCard.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiCard.kt index f40ea0a74..87722e36b 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiCard.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiCard.kt @@ -1,7 +1,9 @@ package dev.dimension.flare.ui.model import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable +@Serializable @Immutable public data class UiCard internal constructor( val title: String, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiDMRoom.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiDMRoom.kt index a05352d7c..4ff2dd0e9 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiDMRoom.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiDMRoom.kt @@ -1,15 +1,15 @@ package dev.dimension.flare.ui.model import androidx.compose.runtime.Immutable +import dev.dimension.flare.common.SerializableImmutableList import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.render.UiDateTime import dev.dimension.flare.ui.render.UiRichText -import kotlinx.collections.immutable.ImmutableList @Immutable public data class UiDMRoom internal constructor( val key: MicroBlogKey, - val users: ImmutableList, + val users: SerializableImmutableList, val lastMessage: UiDMItem?, val unreadCount: Long, ) { @@ -50,7 +50,7 @@ public data class UiDMItem internal constructor( ) : Message public data class Status( - val status: UiTimeline.ItemContent.Status, + val status: UiTimelineV2.Post, ) : Message public data object Deleted : Message diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiEmoji.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiEmoji.kt index 67e540ff5..d887cbb97 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiEmoji.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiEmoji.kt @@ -1,27 +1,29 @@ package dev.dimension.flare.ui.model import androidx.compose.runtime.Immutable -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.ImmutableMap +import dev.dimension.flare.common.SerializableImmutableList +import dev.dimension.flare.common.SerializableImmutableMap +import kotlinx.serialization.Serializable +@Serializable @Immutable public data class UiEmoji internal constructor( val shortcode: String, val url: String, val category: String, - val searchKeywords: List, + val searchKeywords: SerializableImmutableList, val insertText: String, ) // compatibility class for Kotlin native @Immutable public data class EmojiData internal constructor( - val data: ImmutableMap>, + val data: SerializableImmutableMap>, ) { private val list = data.toList() public val size: Int get() = data.size public fun getKey(index: Int): String = list[index].first - public fun getValue(index: Int): ImmutableList = list[index].second + public fun getValue(index: Int): SerializableImmutableList = list[index].second } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiHandle.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiHandle.kt new file mode 100644 index 000000000..bf0fa096b --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiHandle.kt @@ -0,0 +1,20 @@ +package dev.dimension.flare.ui.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable + +@Serializable +@Immutable +public data class UiHandle( + val raw: String, + val host: String, +) { + val normalizedRaw: String + get() = raw.trim().removePrefix("@").substringBefore("@") + + val normalizedHost: String + get() = host.trim().removePrefix("@") + + val canonical: String + get() = "@$normalizedRaw@$normalizedHost" +} 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 new file mode 100644 index 000000000..bc2f5a209 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiIcon.kt @@ -0,0 +1,40 @@ +package dev.dimension.flare.ui.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable + +@Serializable +@Immutable +public enum class UiIcon { + Like, + Unlike, + Retweet, + Unretweet, + Reply, + Comment, + Quote, + Bookmark, + Unbookmark, + More, + MoreVerticel, + Delete, + Report, + React, + UnReact, + Share, + List, + ChatMessage, + Mute, + UnMute, + Block, + UnBlock, + + Follow, + Favourite, + Mention, + Poll, + Edit, + Info, + Pin, + Check, +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiInstanceMetadata.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiInstanceMetadata.kt index e08421116..72524dee3 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiInstanceMetadata.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiInstanceMetadata.kt @@ -1,13 +1,13 @@ package dev.dimension.flare.ui.model import androidx.compose.runtime.Immutable -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.ImmutableMap +import dev.dimension.flare.common.SerializableImmutableList +import dev.dimension.flare.common.SerializableImmutableMap @Immutable public data class UiInstanceMetadata internal constructor( val instance: UiInstance, - val rules: ImmutableMap, + val rules: SerializableImmutableMap, val configuration: Configuration, ) { @Immutable @@ -32,7 +32,7 @@ public data class UiInstanceMetadata internal constructor( public data class MediaAttachment( val imageSizeLimit: Long, val descriptionLimit: Long, - val supportedMimeTypes: ImmutableList, + val supportedMimeTypes: SerializableImmutableList, ) @Immutable diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiMedia.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiMedia.kt index 98bb38cbd..c3a24d149 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiMedia.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiMedia.kt @@ -1,14 +1,17 @@ package dev.dimension.flare.ui.model import androidx.compose.runtime.Immutable -import kotlinx.collections.immutable.ImmutableMap +import dev.dimension.flare.common.SerializableImmutableMap +import kotlinx.serialization.Serializable +@Serializable @Immutable public sealed interface UiMedia { public val url: String public val description: String? - public val customHeaders: ImmutableMap? + public val customHeaders: SerializableImmutableMap? + @Serializable @Immutable public data class Image internal constructor( override val url: String, @@ -17,12 +20,23 @@ public sealed interface UiMedia { val height: Float, val width: Float, val sensitive: Boolean, - override val customHeaders: ImmutableMap? = null, + override val customHeaders: SerializableImmutableMap? = null, ) : UiMedia { + internal constructor(url: String, customHeaders: SerializableImmutableMap? = null) : this( + url = url, + previewUrl = url, + description = null, + height = 0f, + width = 0f, + sensitive = false, + customHeaders = customHeaders, + ) + val aspectRatio: Float get() = (width / (height.takeUnless { it == 0f } ?: 1f)).takeUnless { it == 0f } ?: 1f } + @Serializable @Immutable public data class Video internal constructor( override val url: String, @@ -30,12 +44,13 @@ public sealed interface UiMedia { override val description: String?, val height: Float, val width: Float, - override val customHeaders: ImmutableMap? = null, + override val customHeaders: SerializableImmutableMap? = null, ) : UiMedia { val aspectRatio: Float get() = (width / (height.takeUnless { it == 0f } ?: 1f)).takeUnless { it == 0f } ?: 1f } + @Serializable @Immutable public data class Gif internal constructor( override val url: String, @@ -43,18 +58,19 @@ public sealed interface UiMedia { override val description: String?, val height: Float, val width: Float, - override val customHeaders: ImmutableMap? = null, + override val customHeaders: SerializableImmutableMap? = null, ) : UiMedia { val aspectRatio: Float get() = (width / (height.takeUnless { it == 0f } ?: 1f)).takeUnless { it == 0f } ?: 1f } + @Serializable @Immutable public data class Audio internal constructor( override val url: String, override val description: String?, val previewUrl: String?, - override val customHeaders: ImmutableMap? = null, + override val customHeaders: SerializableImmutableMap? = null, ) : UiMedia } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiPodcast.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiPodcast.kt index 931196e4a..6ef7efab8 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiPodcast.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiPodcast.kt @@ -1,7 +1,7 @@ package dev.dimension.flare.ui.model import androidx.compose.runtime.Immutable -import kotlinx.collections.immutable.ImmutableList +import dev.dimension.flare.common.SerializableImmutableList @Immutable public data class UiPodcast( @@ -10,7 +10,7 @@ public data class UiPodcast( val playbackUrl: String?, val ended: Boolean, val creator: UiProfile, - val hosts: ImmutableList, - val speakers: ImmutableList, - val listeners: ImmutableList, + val hosts: SerializableImmutableList, + val speakers: SerializableImmutableList, + val listeners: SerializableImmutableList, ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiPoll.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiPoll.kt index 04d2111ba..3d9ea5f8f 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiPoll.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiPoll.kt @@ -1,20 +1,24 @@ package dev.dimension.flare.ui.model import androidx.compose.runtime.Immutable +import dev.dimension.flare.common.SerializableImmutableList +import dev.dimension.flare.data.datasource.microblog.PostEvent import dev.dimension.flare.ui.humanizer.humanizePercentage import dev.dimension.flare.ui.render.UiDateTime import dev.dimension.flare.ui.render.toUi import kotlinx.collections.immutable.ImmutableList +import kotlinx.serialization.Serializable import kotlin.time.Clock import kotlin.time.Instant +@Serializable @Immutable public data class UiPoll internal constructor( val id: String, - val options: ImmutableList