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 c25c683e4..32a6a4a80 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 @@ -47,10 +47,10 @@ import dev.dimension.flare.ui.component.status.AdaptiveCard import dev.dimension.flare.ui.component.status.CommonStatusHeaderComponent import dev.dimension.flare.ui.component.status.UserPlaceholder 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.UiUserV2 import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.home.SearchHistoryPresenter import dev.dimension.flare.ui.presenter.home.SearchHistoryState @@ -165,7 +165,7 @@ private fun SearchContent( } internal fun LazyStaggeredGridScope.searchContent( - searchUsers: PagingState, + searchUsers: PagingState, searchStatus: PagingState, toUser: (MicroBlogKey) -> Unit, ) { 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 d310ba1bb..482cf3bb0 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 @@ -364,6 +364,20 @@ internal sealed interface Route : NavKey { val title: String, ) : Misskey, WithAccountType + + @Serializable + data class ChannelList( + override val accountType: AccountType, + ) : Misskey, + WithAccountType + + @Serializable + data class ChannelTimeline( + override val accountType: AccountType, + val channelId: String, + val title: String, + ) : Misskey, + WithAccountType } @Serializable 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 e296d033e..d7f5ab79a 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 @@ -1300,6 +1300,9 @@ internal val UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.localNa UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Specified -> R.string.misskey_visibility_specified + + UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Channel -> + R.string.misskey_visibility_public } internal val UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.localDescription: Int @@ -1316,4 +1319,7 @@ internal val UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.localDe UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Specified -> R.string.misskey_visibility_specified_description + + UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Channel -> + R.string.misskey_visibility_public_description } 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 52cfbba5a..3c4a311d4 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 @@ -81,8 +81,8 @@ import dev.dimension.flare.ui.component.TabTitle import dev.dimension.flare.ui.component.TopLevelBackStack import dev.dimension.flare.ui.component.listCard import dev.dimension.flare.ui.component.platform.isBigScreen +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiState -import dev.dimension.flare.ui.model.UiUserV2 import dev.dimension.flare.ui.model.isError import dev.dimension.flare.ui.model.isSuccess import dev.dimension.flare.ui.model.onLoading @@ -311,7 +311,7 @@ internal fun HomeScreen(afterInit: () -> Unit) { @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalSharedTransitionApi::class) private fun HomeRailHeader( wideNavigationRailState: WideNavigationRailState, - userState: UiState, + userState: UiState, layoutType: NavigationSuiteType, currentRoute: Route, navigate: (Route) -> Unit, @@ -536,6 +536,7 @@ private fun getDirection( is DirectMessageTabItem -> Route.DM.List(accountType) is RssTabItem -> Route.Rss.Sources is Misskey.AntennasListTabItem -> Route.Misskey.AntennasList(accountType) + is Misskey.ChannelListTabItem -> Route.Misskey.ChannelList(accountType) } @Composable diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/list/CreateListDialog.kt b/app/src/main/java/dev/dimension/flare/ui/screen/list/CreateListDialog.kt index 840fea08e..ccecece83 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/list/CreateListDialog.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/list/CreateListDialog.kt @@ -38,8 +38,8 @@ import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.Rss import dev.dimension.flare.R import dev.dimension.flare.common.FileItem -import dev.dimension.flare.data.datasource.microblog.ListMetaData -import dev.dimension.flare.data.datasource.microblog.ListMetaDataType +import dev.dimension.flare.data.datasource.microblog.list.ListMetaData +import dev.dimension.flare.data.datasource.microblog.list.ListMetaDataType import dev.dimension.flare.model.AccountType import dev.dimension.flare.ui.component.AvatarComponentDefaults import dev.dimension.flare.ui.component.FAIcon diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/list/EditAccountListScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/list/EditAccountListScreen.kt index 0e90837b5..d76532da1 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/list/EditAccountListScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/list/EditAccountListScreen.kt @@ -68,9 +68,10 @@ internal fun EditAccountListScreen( uiListItemComponent( state.lists, ) { item -> - state.userLists - .onSuccess { - if (it.any { list -> list.id == item.id }) { + state + .isInList(item) + .onSuccess { inList -> + if (inList) { IconButton( onClick = { state.removeList(item) }, ) { diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/list/EditListMemberScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/list/EditListMemberScreen.kt index 4578ab7d8..a1e39fd86 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/list/EditListMemberScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/list/EditListMemberScreen.kt @@ -45,7 +45,6 @@ import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.list.EditListMemberPresenter import dev.dimension.flare.ui.presenter.list.EditListMemberState -import dev.dimension.flare.ui.presenter.list.EmptyQueryException import dev.dimension.flare.ui.screen.settings.AccountItem import dev.dimension.flare.ui.theme.screenHorizontalPadding import moe.tlaster.precompose.molecule.producePresenter @@ -188,28 +187,15 @@ internal fun EditListMemberScreen( ) } }.onError { - if (it is EmptyQueryException) { - item { - ListItem( - headlineContent = { - Text(text = stringResource(id = R.string.edit_list_member_search_empty)) - }, - modifier = - Modifier - .listCard(), - ) - } - } else { - item { - ListItem( - headlineContent = { - Text(text = stringResource(id = R.string.edit_list_member_search_error)) - }, - modifier = - Modifier - .listCard(), - ) - } + item { + ListItem( + headlineContent = { + Text(text = stringResource(id = R.string.edit_list_member_search_error)) + }, + modifier = + Modifier + .listCard(), + ) } } } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/list/EditListScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/list/EditListScreen.kt index f6c166d3b..3dbe85c8d 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/list/EditListScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/list/EditListScreen.kt @@ -53,8 +53,8 @@ import dev.dimension.flare.common.onEmpty import dev.dimension.flare.common.onError import dev.dimension.flare.common.onLoading import dev.dimension.flare.common.onSuccess -import dev.dimension.flare.data.datasource.microblog.ListMetaData -import dev.dimension.flare.data.datasource.microblog.ListMetaDataType +import dev.dimension.flare.data.datasource.microblog.list.ListMetaData +import dev.dimension.flare.data.datasource.microblog.list.ListMetaDataType import dev.dimension.flare.model.AccountType import dev.dimension.flare.ui.component.AvatarComponentDefaults import dev.dimension.flare.ui.component.BackButton @@ -170,9 +170,16 @@ internal fun EditListScreen( } else { state.listInfo .onSuccess { - if (it.avatar != null) { + val avatar = + when (it) { + is dev.dimension.flare.ui.model.UiList.List -> it.avatar + is dev.dimension.flare.ui.model.UiList.Feed -> it.avatar + is dev.dimension.flare.ui.model.UiList.Channel -> it.banner + is dev.dimension.flare.ui.model.UiList.Antenna -> null + } + if (avatar != null) { NetworkImage( - model = it.avatar, + model = avatar, contentDescription = null, modifier = Modifier @@ -380,7 +387,16 @@ private fun presenter( append(it.title) } description.edit { - append(it.description) + val desc = + when (it) { + is dev.dimension.flare.ui.model.UiList.List -> it.description + is dev.dimension.flare.ui.model.UiList.Feed -> it.description + is dev.dimension.flare.ui.model.UiList.Channel -> null + is dev.dimension.flare.ui.model.UiList.Antenna -> null + } + if (desc != null) { + append(desc) + } } } } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/media/PodcastScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/media/PodcastScreen.kt index 2950bd277..5757eedad 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/media/PodcastScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/media/PodcastScreen.kt @@ -28,8 +28,8 @@ import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.component.AvatarComponent import dev.dimension.flare.ui.component.RichText import dev.dimension.flare.ui.model.UiPodcast +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiState -import dev.dimension.flare.ui.model.UiUserV2 import dev.dimension.flare.ui.model.map import dev.dimension.flare.ui.model.onError import dev.dimension.flare.ui.model.onLoading @@ -150,7 +150,7 @@ internal fun ColumnScope.PodcastContent( @Composable private fun UserItem( - item: UiUserV2, + item: UiProfile, modifier: Modifier = Modifier, bottom: @Composable () -> Unit = {}, ) { diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/misskey/ChannelListScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/misskey/ChannelListScreen.kt new file mode 100644 index 000000000..f19fad997 --- /dev/null +++ b/app/src/main/java/dev/dimension/flare/ui/screen/misskey/ChannelListScreen.kt @@ -0,0 +1,217 @@ +package dev.dimension.flare.ui.screen.misskey + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.SecondaryScrollableTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.EllipsisVertical +import compose.icons.fontawesomeicons.solid.HeartCircleMinus +import compose.icons.fontawesomeicons.solid.HeartCirclePlus +import compose.icons.fontawesomeicons.solid.Minus +import compose.icons.fontawesomeicons.solid.Plus +import dev.dimension.flare.R +import dev.dimension.flare.common.isRefreshing +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.ui.common.plus +import dev.dimension.flare.ui.component.BackButton +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.FlareLargeFlexibleTopAppBar +import dev.dimension.flare.ui.component.FlareScaffold +import dev.dimension.flare.ui.component.RefreshContainer +import dev.dimension.flare.ui.component.TabRowIndicator +import dev.dimension.flare.ui.component.uiListItemComponent +import dev.dimension.flare.ui.model.UiList +import dev.dimension.flare.ui.presenter.home.misskey.MisskeyChannelListPresenter +import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.theme.screenHorizontalPadding +import moe.tlaster.precompose.molecule.producePresenter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ChannelListScreen( + accountType: AccountType, + toTimeline: (UiList) -> Unit, + onBack: () -> Unit, +) { + val state by producePresenter { + remember { + MisskeyChannelListPresenter(accountType) + }.invoke() + } + val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + FlareScaffold( + modifier = Modifier.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), + topBar = { + FlareLargeFlexibleTopAppBar( + title = { + Text(text = stringResource(id = R.string.home_tab_channels_title)) + }, + subtitle = { + SecondaryScrollableTabRow( + containerColor = Color.Transparent, + modifier = + Modifier + .fillMaxWidth(), + indicator = { + TabRowIndicator( + selectedIndex = state.allTypes.indexOf(state.type), + ) + }, + minTabWidth = 48.dp, + selectedTabIndex = state.allTypes.indexOf(state.type), + divider = {}, + edgePadding = screenHorizontalPadding, + ) { + state.allTypes.forEach { type -> + Tab( + selected = state.type == type, + onClick = { + state.setType(type) + }, + text = { + Text( + stringResource( + when (type) { + MisskeyChannelListPresenter.State.Type.Following -> + R.string.misskey_channel_tab_following + + MisskeyChannelListPresenter.State.Type.Favorites -> + R.string.misskey_channel_tab_favorites + + MisskeyChannelListPresenter.State.Type.Owned -> + R.string.misskey_channel_tab_owned + + MisskeyChannelListPresenter.State.Type.Featured -> + R.string.misskey_channel_tab_featured + }, + ), + ) + }, + ) + } + } + }, + scrollBehavior = topAppBarScrollBehavior, + navigationIcon = { + BackButton(onBack = onBack) + }, + ) + }, + ) { contentPadding -> + RefreshContainer( + isRefreshing = state.data.isRefreshing, + onRefresh = { state.refresh() }, + indicatorPadding = contentPadding, + content = { + LazyColumn( + contentPadding = + contentPadding + + PaddingValues( + vertical = 16.dp, + horizontal = screenHorizontalPadding, + ), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + uiListItemComponent( + items = state.data, + onClicked = toTimeline, + trailingContent = { item -> + if (item is UiList.Channel) { + var showMenu by remember { mutableStateOf(false) } + IconButton( + onClick = { + showMenu = true + }, + ) { + FAIcon( + FontAwesomeIcons.Solid.EllipsisVertical, + contentDescription = stringResource(R.string.more), + ) + DropdownMenu( + expanded = showMenu, + onDismissRequest = { + showMenu = false + }, + ) { + if (item.isFollowing == true) { + DropdownMenuItem( + text = { + Text(stringResource(R.string.channel_item_unfollow)) + }, + onClick = { + state.unfollow(item) + }, + leadingIcon = { + FAIcon( + FontAwesomeIcons.Solid.Minus, + contentDescription = stringResource(R.string.channel_item_unfollow), + ) + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.channel_item_follow)) }, + onClick = { state.follow(item) }, + leadingIcon = { + FAIcon( + FontAwesomeIcons.Solid.Plus, + contentDescription = stringResource(R.string.channel_item_follow), + ) + }, + ) + } + if (item.isFavorited == true) { + DropdownMenuItem( + text = { Text(stringResource(R.string.channel_item_unfavourite)) }, + onClick = { state.unfavorite(item) }, + leadingIcon = { + FAIcon( + FontAwesomeIcons.Solid.HeartCircleMinus, + contentDescription = stringResource(R.string.channel_item_unfavourite), + ) + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.channel_item_favourite)) }, + onClick = { state.favorite(item) }, + leadingIcon = { + FAIcon( + FontAwesomeIcons.Solid.HeartCirclePlus, + contentDescription = stringResource(R.string.channel_item_favourite), + ) + }, + ) + } + } + } + } + }, + ) + } + }, + ) + } +} diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/misskey/MisskeyEntryBuilder.kt b/app/src/main/java/dev/dimension/flare/ui/screen/misskey/MisskeyEntryBuilder.kt index ebe7bdc4f..c5df70cea 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/misskey/MisskeyEntryBuilder.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/misskey/MisskeyEntryBuilder.kt @@ -18,6 +18,7 @@ import androidx.navigation3.runtime.NavKey import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.SquareRss +import compose.icons.fontawesomeicons.solid.List import dev.dimension.flare.data.model.IconType import dev.dimension.flare.data.model.Misskey import dev.dimension.flare.data.model.TabMetaData @@ -68,6 +69,43 @@ internal fun EntryProviderScope.misskeyEntryBuilder( onBack = onBack, ) } + + entry( + metadata = ListDetailSceneStrategy.listPane( + sceneKey = "misskey_channels_list", + detailPlaceholder = { + ChannelsPlaceholder() + } + ) + ) { args -> + ChannelListScreen( + accountType = args.accountType, + toTimeline = { + navigate(Route.Misskey.ChannelTimeline(args.accountType, it.id, it.title)) + }, + onBack = onBack + ) + } + + entry( + metadata = ListDetailSceneStrategy.detailPane( + sceneKey = "misskey_channels_list", + ) + ) { args -> + TimelineScreen( + tabItem = remember(args) { + Misskey.ChannelTimelineTabItem( + channelId = args.channelId, + account = args.accountType, + metaData = TabMetaData( + title = TitleType.Text(args.title), + icon = IconType.Material(IconType.Material.MaterialIcon.List), + ), + ) + }, + onBack = onBack, + ) + } } @@ -93,3 +131,26 @@ internal fun AntennasPlaceholder( } } } + +@Composable +internal fun ChannelsPlaceholder( + modifier: Modifier = Modifier, +) { + FlareScaffold( + modifier = modifier, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(it), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically), + ) { + FAIcon( + FontAwesomeIcons.Solid.List, + contentDescription = null, + modifier = Modifier.size(64.dp) + ) + } + } +} diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/profile/UserListScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/profile/UserListScreen.kt index 5b9d15cb1..4b0139d12 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/profile/UserListScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/profile/UserListScreen.kt @@ -26,8 +26,8 @@ import dev.dimension.flare.ui.component.FlareLargeFlexibleTopAppBar import dev.dimension.flare.ui.component.FlareScaffold import dev.dimension.flare.ui.component.RefreshContainer import dev.dimension.flare.ui.component.listCard +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiState -import dev.dimension.flare.ui.model.UiUserV2 import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.profile.FansPresenter import dev.dimension.flare.ui.presenter.profile.FollowingPresenter @@ -112,7 +112,7 @@ internal fun FansScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun UserListScreen( - data: PagingState, + data: PagingState, title: @Composable () -> Unit, onBack: () -> Unit, isRefreshing: Boolean, 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 bcb6390d0..b65e84e6f 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 @@ -59,8 +59,8 @@ import dev.dimension.flare.ui.component.RichText import dev.dimension.flare.ui.component.ThemeIconData import dev.dimension.flare.ui.component.ThemedIcon import dev.dimension.flare.ui.component.placeholder +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiState -import dev.dimension.flare.ui.model.UiUserV2 import dev.dimension.flare.ui.model.isError import dev.dimension.flare.ui.model.isSuccess import dev.dimension.flare.ui.model.onError @@ -232,16 +232,16 @@ internal fun AccountsScreen( @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun AccountItem( - userState: UiState, +fun AccountItem( + userState: UiState, onClick: (MicroBlogKey) -> Unit, toLogin: () -> Unit, modifier: Modifier = Modifier, - trailingContent: @Composable (UiUserV2) -> Unit = { }, - headlineContent: @Composable (UiUserV2) -> Unit = { + trailingContent: @Composable (UiProfile) -> Unit = { }, + headlineContent: @Composable (UiProfile) -> Unit = { RichText(text = it.name, maxLines = 1) }, - supportingContent: @Composable (UiUserV2) -> Unit = { + supportingContent: @Composable (UiProfile) -> Unit = { Text(text = it.handle, maxLines = 1) }, avatarSize: Dp = AvatarComponentDefaults.size, 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 10d4b6318..7167bc5d0 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 @@ -238,6 +238,9 @@ internal fun TabAddBottomSheet( is PinnableTimelineTabPresenter.State.Tab.Antenna -> R.string.home_tab_antennas_title + + is PinnableTimelineTabPresenter.State.Tab.Channel -> + R.string.channel_title } }.map { stringResource(id = it) } ButtonGroup( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1e196702e..b6e4086ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -374,6 +374,11 @@ Podcast live Antenna + Channels + Following + Favorites + Owned + Featured No antennas Mixed Add mixed timeline tab @@ -419,4 +424,9 @@ Group name New Group Theme + Channel + Follow + Unfollow + Favourite + Unfavourite diff --git a/compose-ui/src/commonMain/composeResources/values/strings.xml b/compose-ui/src/commonMain/composeResources/values/strings.xml index cfa6d96ac..043c2c0f5 100644 --- a/compose-ui/src/commonMain/composeResources/values/strings.xml +++ b/compose-ui/src/commonMain/composeResources/values/strings.xml @@ -83,6 +83,9 @@ Click to login again Login again + Permission Denied + You need re-login in order to access this page + Local only Only users on this instance can see this post @@ -490,4 +493,5 @@ Crowdin Help us translate Flare Privacy Policy + Channel 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 40e545f48..01d00f8b6 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 @@ -7,8 +7,8 @@ import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType import dev.dimension.flare.ui.model.UiAccount import dev.dimension.flare.ui.model.UiList +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiRssSource -import dev.dimension.flare.ui.model.UiUserV2 import dev.dimension.flare.ui.presenter.home.HomeTimelinePresenter import dev.dimension.flare.ui.presenter.home.MixedTimelinePresenter import dev.dimension.flare.ui.presenter.home.TimelinePresenter @@ -92,6 +92,7 @@ public sealed class TitleType { Liked, AllRssFeeds, Posts, + Channel, } } } @@ -142,6 +143,7 @@ public sealed class IconType { Messages, Rss, Weibo, + Channel, } } @@ -252,7 +254,7 @@ public sealed class TimelineTabItem : TabItem() { SettingsTabItem, ) - public fun defaultPrimary(user: UiUserV2): ImmutableList = + public fun defaultPrimary(user: UiProfile): ImmutableList = when (user.platformType) { PlatformType.Mastodon -> mastodon(user.key) PlatformType.Misskey -> misskey(user.key) @@ -273,7 +275,7 @@ public sealed class TimelineTabItem : TabItem() { return result.toImmutableList() } - public fun secondaryFor(user: UiUserV2): ImmutableList = + public fun secondaryFor(user: UiProfile): ImmutableList = when (user.platformType) { PlatformType.Mastodon -> defaultMastodonSecondaryItems(user.key) PlatformType.Misskey -> defaultMisskeySecondaryItems(user.key) @@ -424,6 +426,14 @@ public sealed class TimelineTabItem : TabItem() { icon = IconType.Mixed(IconType.Material.MaterialIcon.Rss, accountKey), ), ), + Misskey.ChannelListTabItem( + account = AccountType.Specific(accountKey), + metaData = + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.Channel), + icon = IconType.Mixed(IconType.Material.MaterialIcon.Channel, accountKey), + ), + ), ) private fun bluesky(accountKey: MicroBlogKey) = @@ -851,6 +861,47 @@ public object Misskey { override fun update(metaData: TabMetaData): TabItem = copy(metaData = metaData) } + + @Immutable + @Serializable + public data class ChannelTimelineTabItem( + val channelId: String, + override val account: AccountType, + override val metaData: TabMetaData, + ) : TimelineTabItem() { + public constructor(accountKey: MicroBlogKey, data: UiList) : this( + channelId = data.id, + account = AccountType.Specific(accountKey), + metaData = + TabMetaData( + title = TitleType.Text(data.title), + icon = + IconType.Mixed( + icon = IconType.Material.MaterialIcon.List, + userKey = accountKey, + ), + ), + ) + + override val key: String = "channel_${account}_$channelId" + + override fun createPresenter(): TimelinePresenter = + dev.dimension.flare.ui.presenter.list + .ChannelTimelinePresenter(account, channelId) + + override fun update(metaData: TabMetaData): TabItem = copy(metaData = metaData) + } + + @Immutable + @Serializable + public data class ChannelListTabItem( + override val account: AccountType, + override val metaData: TabMetaData, + ) : TabItem() { + override val key: String = "channels_$account" + + override fun update(metaData: TabMetaData): TabItem = copy(metaData = metaData) + } } public object XQT { diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/BuildContentAnnotatedString.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/BuildContentAnnotatedString.kt index 2e61c2d61..6a73daea6 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/BuildContentAnnotatedString.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/BuildContentAnnotatedString.kt @@ -59,9 +59,11 @@ private fun buildContentAnnotatedString( internal class BuildContentAnnotatedStringContext { private var isBlockState = false + sealed interface InlineType { data class Emoji(val url: String) : InlineType } + val inlineContent = mutableMapOf() fun appendInlineContent( type: InlineType, @@ -69,12 +71,15 @@ internal class BuildContentAnnotatedStringContext { val id = "inline_${inlineContent.size}" inlineContent[id] = type } + fun pushBlockState() { isBlockState = true } + fun popBlockState() { isBlockState = false } + fun isInBlockState(): Boolean = isBlockState fun appendImageInlineContent( url: String, @@ -194,7 +199,7 @@ private class ContentBuilder( private fun restoreStyles() { activeStyleOps.forEach { op -> - when(op) { + when (op) { is StyleOp.Span -> currentBuilder.pushStyle(op.style) is StyleOp.Paragraph -> currentBuilder.pushStyle(op.style) is StyleOp.Annotation -> currentBuilder.pushStringAnnotation(op.tag, op.annotation) @@ -210,6 +215,7 @@ private class ContentBuilder( } } + private fun ContentBuilder.renderNode( node: Node, styleData: StyleData, @@ -223,6 +229,7 @@ private fun ContentBuilder.renderNode( is TextNode -> { renderText(node.text()) } + else -> Unit } } @@ -233,6 +240,7 @@ private fun ContentBuilder.renderText( append(text) } + private fun ContentBuilder.renderElement( element: Element, styleData: StyleData, @@ -249,12 +257,22 @@ private fun ContentBuilder.renderElement( "center" -> { val style = - styleData.textStyle.copy(textAlign = androidx.compose.ui.text.style.TextAlign.Center) + styleData.textStyle.copy( + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) withStyle( - style.toSpanStyle(), + style.toParagraphStyle() ) { - element.childNodes().fastForEach { - renderNode(node = it, styleData = styleData, context = context) + withStyle(style.toSpanStyle()) { + element.childNodes().fastForEach { + renderNode( + node = it, + styleData = styleData.copy( + style = style, + ), + context = context, + ) + } } } } @@ -340,51 +358,105 @@ private fun ContentBuilder.renderElement( } "strong", "b" -> { + val style = styleData.textStyle.copy(fontWeight = FontWeight.Bold) pushStyle( - styleData.textStyle.copy(fontWeight = FontWeight.Bold).toSpanStyle(), + style.toSpanStyle(), ) element.childNodes().fastForEach { - renderNode(node = it, styleData = styleData, context = context) + renderNode( + node = it, + styleData = styleData.copy( + style = style, + linkStyle = styleData.linkStyle.copy( + fontWeight = FontWeight.Bold + ), + ), + context = context + ) } pop() } "em", "i" -> { + val style = styleData.textStyle.copy(fontStyle = FontStyle.Italic) pushStyle( - styleData.textStyle.copy(fontStyle = FontStyle.Italic).toSpanStyle(), + style.toSpanStyle(), ) element.childNodes().fastForEach { - renderNode(node = it, styleData = styleData, context = context) + renderNode( + node = it, + styleData = styleData.copy( + style = style, + linkStyle = styleData.linkStyle.copy( + fontStyle = FontStyle.Italic + ), + ), + context = context + ) } pop() } "del", "s" -> { + val style = styleData.textStyle.copy(textDecoration = TextDecoration.LineThrough) pushStyle( - styleData.textStyle.copy(textDecoration = TextDecoration.LineThrough).toSpanStyle(), + style.toSpanStyle(), ) element.childNodes().fastForEach { - renderNode(node = it, styleData = styleData, context = context) + renderNode( + node = it, + styleData = styleData.copy( + style = style, + linkStyle = styleData.linkStyle.copy( + textDecoration = TextDecoration.LineThrough + ), + ), + context = context + ) } pop() } "u" -> { + val style = styleData.textStyle.copy(textDecoration = TextDecoration.Underline) pushStyle( - styleData.textStyle.copy(textDecoration = TextDecoration.Underline).toSpanStyle(), + style.toSpanStyle(), ) element.childNodes().fastForEach { - renderNode(node = it, styleData = styleData, context = context) + renderNode( + node = it, + styleData = styleData.copy( + style = style, + linkStyle = styleData.linkStyle.copy( + textDecoration = TextDecoration.Underline + ), + ), + context = context + ) } pop() } "small" -> { + val style = styleData.textStyle.copy( + fontSize = styleData.textStyle.fontSize * 0.8, + color = styleData.color.copy(alpha = 0.7f), + ) pushStyle( - styleData.textStyle.copy(fontSize = styleData.textStyle.fontSize * 0.8).toSpanStyle(), + style.toSpanStyle(), ) element.childNodes().fastForEach { - renderNode(node = it, styleData = styleData, context = context) + renderNode( + node = it, + styleData = styleData.copy( + style = style, + linkStyle = styleData.linkStyle.copy( + fontSize = styleData.linkStyle.fontSize * 0.8, + color = styleData.linkStyle.color.copy(alpha = 0.7f), + ), + ), + context = context + ) } pop() } @@ -398,7 +470,6 @@ private fun ContentBuilder.renderElement( } "ul" -> { - element.childNodes().fastForEach { renderNode(node = it, styleData = styleData, context = context) } @@ -519,6 +590,25 @@ private fun ContentBuilder.renderElement( appendLine() } + "time" -> { + val style = + styleData.textStyle.copy( + color = styleData.color.copy(alpha = 0.7f), + background = styleData.color.copy(alpha = 0.05f), + ) + withStyle(style.toSpanStyle()) { + element.childNodes().fastForEach { + renderNode( + node = it, + styleData = styleData.copy( + style = style + ), + context = context, + ) + } + } + } + else -> { element.childNodes().fastForEach { renderNode(node = it, styleData = styleData, context = context) diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ErrorContent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ErrorContent.kt new file mode 100644 index 000000000..93ba6fc49 --- /dev/null +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ErrorContent.kt @@ -0,0 +1,140 @@ +package dev.dimension.flare.ui.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.CircleExclamation +import compose.icons.fontawesomeicons.solid.FileCircleExclamation +import dev.dimension.flare.compose.ui.Res +import dev.dimension.flare.compose.ui.login_expired +import dev.dimension.flare.compose.ui.login_expired_message +import dev.dimension.flare.compose.ui.permission_denied_message +import dev.dimension.flare.compose.ui.permission_denied_title +import dev.dimension.flare.compose.ui.status_loadmore_error +import dev.dimension.flare.data.repository.LoginExpiredException +import dev.dimension.flare.data.repository.RequireReLoginException +import dev.dimension.flare.ui.component.platform.PlatformText +import dev.dimension.flare.ui.route.DeeplinkRoute +import dev.dimension.flare.ui.route.toUri +import org.jetbrains.compose.resources.stringResource + +@Composable +public fun ErrorContent( + error: Throwable, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + when (error) { + is LoginExpiredException -> { + LoginExpiredError(error, modifier) + } + + is RequireReLoginException -> { + RequireReLoginError(error, modifier) + } + else -> { + CommonError( + error = error, + onRetry = onRetry, + modifier = modifier, + ) + } + } +} + +@Composable +private fun CommonError( + error: Throwable, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .clickable { + onRetry.invoke() + }.fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.FileCircleExclamation, + contentDescription = null, + modifier = Modifier.size(48.dp), + ) + PlatformText(text = stringResource(Res.string.status_loadmore_error)) + error.message?.let { PlatformText(text = it) } + } +} + +@Composable +private fun RequireReLoginError( + error: RequireReLoginException, + modifier: Modifier = Modifier, +) { + val uriHandler = LocalUriHandler.current + Column( + modifier = + modifier + .clickable { + uriHandler.openUri(DeeplinkRoute.Login.toUri()) + }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.CircleExclamation, + contentDescription = null, + modifier = Modifier.size(48.dp), + ) + PlatformText( + text = stringResource(resource = Res.string.permission_denied_title), + ) + PlatformText( + text = stringResource(resource = Res.string.permission_denied_message), + ) + } +} + +@Composable +private fun LoginExpiredError( + error: LoginExpiredException, + modifier: Modifier = Modifier, +) { + val uriHandler = LocalUriHandler.current + Column( + modifier = + modifier + .clickable { + uriHandler.openUri(DeeplinkRoute.Login.toUri()) + }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.CircleExclamation, + contentDescription = null, + modifier = Modifier.size(48.dp), + ) + PlatformText( + text = stringResource(resource = Res.string.login_expired), + ) + PlatformText( + text = stringResource(resource = Res.string.login_expired_message), + ) + PlatformText( + text = error.accountKey.toString(), + ) + } +} diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/TabIcon.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/TabIcon.kt index c843a9252..09561aebc 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/TabIcon.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/TabIcon.kt @@ -36,10 +36,12 @@ import compose.icons.fontawesomeicons.solid.MagnifyingGlass import compose.icons.fontawesomeicons.solid.Message import compose.icons.fontawesomeicons.solid.RectangleList import compose.icons.fontawesomeicons.solid.SquareRss +import compose.icons.fontawesomeicons.solid.Tv import compose.icons.fontawesomeicons.solid.Users import dev.dimension.flare.compose.ui.Res import dev.dimension.flare.compose.ui.all_rss_feeds_title import dev.dimension.flare.compose.ui.antenna_title +import dev.dimension.flare.compose.ui.channel_title import dev.dimension.flare.compose.ui.dm_list_title import dev.dimension.flare.compose.ui.home_tab_bookmarks_title import dev.dimension.flare.compose.ui.home_tab_discover_title @@ -62,7 +64,6 @@ import dev.dimension.flare.data.model.IconType import dev.dimension.flare.data.model.TabItem import dev.dimension.flare.data.model.TitleType import dev.dimension.flare.model.AccountType -import dev.dimension.flare.ui.component.placeholder import dev.dimension.flare.ui.component.platform.PlatformText import dev.dimension.flare.ui.component.platform.PlatformTextStyle import dev.dimension.flare.ui.icons.Misskey @@ -283,6 +284,7 @@ internal val TitleType.Localized.res: StringResource TitleType.Localized.LocalizedKey.Liked -> Res.string.liked_title TitleType.Localized.LocalizedKey.AllRssFeeds -> Res.string.all_rss_feeds_title TitleType.Localized.LocalizedKey.Posts -> Res.string.posts_title + TitleType.Localized.LocalizedKey.Channel -> Res.string.channel_title } internal fun IconType.Material.MaterialIcon.toIcon(): ImageVector = @@ -306,4 +308,5 @@ internal fun IconType.Material.MaterialIcon.toIcon(): ImageVector = IconType.Material.MaterialIcon.Messages -> FontAwesomeIcons.Solid.Message IconType.Material.MaterialIcon.Rss -> FontAwesomeIcons.Solid.SquareRss IconType.Material.MaterialIcon.Weibo -> FontAwesomeIcons.Brands.Weibo + IconType.Material.MaterialIcon.Channel -> FontAwesomeIcons.Solid.Tv } diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/UiListItemComponent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/UiListItemComponent.kt index c8382847c..0ccbd402d 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/UiListItemComponent.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/UiListItemComponent.kt @@ -18,16 +18,13 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid -import compose.icons.fontawesomeicons.solid.CircleExclamation import compose.icons.fontawesomeicons.solid.List import compose.icons.fontawesomeicons.solid.Rss import dev.dimension.flare.common.PagingState import dev.dimension.flare.compose.ui.Res import dev.dimension.flare.compose.ui.feeds_discover_feeds_created_by import dev.dimension.flare.compose.ui.list_empty -import dev.dimension.flare.compose.ui.list_error import dev.dimension.flare.ui.common.itemsIndexed -import dev.dimension.flare.ui.component.placeholder import dev.dimension.flare.ui.component.platform.PlatformListItem import dev.dimension.flare.ui.component.platform.PlatformSegmentedListItem import dev.dimension.flare.ui.component.platform.PlatformText @@ -36,10 +33,10 @@ import dev.dimension.flare.ui.theme.PlatformTheme import dev.dimension.flare.ui.theme.screenHorizontalPadding import org.jetbrains.compose.resources.stringResource -public fun LazyListScope.uiListItemComponent( - items: PagingState, - onClicked: ((UiList) -> Unit)? = null, - trailingContent: @Composable RowScope.(UiList) -> Unit = {}, +public fun LazyListScope.uiListItemComponent( + items: PagingState, + onClicked: ((T) -> Unit)? = null, + trailingContent: @Composable RowScope.(T) -> Unit = {}, ) { itemsIndexed( items, @@ -76,25 +73,21 @@ public fun LazyListScope.uiListItemComponent( ) }, errorContent = { - Column( + ErrorContent( + error = it, modifier = Modifier.fillParentMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), - ) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.CircleExclamation, - contentDescription = stringResource(Res.string.list_error), - modifier = Modifier.size(48.dp), - ) - PlatformText( - text = stringResource(Res.string.list_error), - style = PlatformTheme.typography.title, - ) - } + onRetry = { + }, + ) }, ) { index, itemCount, item -> UiListItem( - onClicked = onClicked, + onClicked = + onClicked?.let { + { + it.invoke(item) + } + }, item = item, trailingContent = trailingContent, index = index, @@ -104,15 +97,73 @@ public fun LazyListScope.uiListItemComponent( } @Composable -public fun UiListItem( - onClicked: ((UiList) -> Unit)?, - item: UiList, - trailingContent: @Composable (RowScope.(UiList) -> Unit), +public fun UiListItem( + onClicked: (() -> Unit)?, + item: T, + trailingContent: @Composable (RowScope.(T) -> Unit), index: Int, totalCount: Int, modifier: Modifier = Modifier, ) { - if (item.description?.takeIf { it.isNotEmpty() } != null) { + when (item) { + is UiList.List -> + UiListCard( + item = item, + onClicked = onClicked, + trailingContent = { + trailingContent.invoke(this, item) + }, + index = index, + totalCount = totalCount, + modifier = modifier, + ) + is UiList.Feed -> + UiFeedCard( + item = item, + onClicked = onClicked, + trailingContent = { + trailingContent.invoke(this, item) + }, + index = index, + totalCount = totalCount, + modifier = modifier, + ) + is UiList.Antenna -> + UiAntennaCard( + item = item, + onClicked = onClicked, + trailingContent = { + trailingContent.invoke(this, item) + }, + index = index, + totalCount = totalCount, + modifier = modifier, + ) + is UiList.Channel -> + UiChannelCard( + item = item, + onClicked = onClicked, + trailingContent = { + trailingContent.invoke(this, item) + }, + index = index, + totalCount = totalCount, + modifier = modifier, + ) + } +} + +@Composable +private fun UiListCard( + item: UiList.List, + onClicked: (() -> Unit)?, + trailingContent: @Composable (RowScope.() -> Unit), + index: Int, + totalCount: Int, + modifier: Modifier = Modifier, +) { + val description = item.description + if (!description.isNullOrEmpty()) { Column( modifier = modifier @@ -124,17 +175,12 @@ public fun UiListItem( if (onClicked == null) { it } else { - it - .clickable { - onClicked(item) - } + it.clickable { onClicked() } } }, ) { PlatformListItem( - headlineContent = { - PlatformText(text = item.title) - }, + headlineContent = { PlatformText(text = item.title) }, leadingContent = { if (item.avatar != null) { NetworkImage( @@ -147,7 +193,7 @@ public fun UiListItem( ) } else { FAIcon( - imageVector = FontAwesomeIcons.Solid.Rss, + imageVector = FontAwesomeIcons.Solid.List, contentDescription = null, modifier = Modifier @@ -174,36 +220,164 @@ public fun UiListItem( } }, trailingContent = { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - trailingContent.invoke(this, item) + Row(verticalAlignment = Alignment.CenterVertically) { + trailingContent.invoke(this) } }, ) - item.description?.takeIf { it.isNotEmpty() }?.let { - PlatformText( - text = it, - modifier = - Modifier - .background(PlatformTheme.colorScheme.card) - .fillMaxWidth() - .padding(bottom = 8.dp) - .padding(horizontal = screenHorizontalPadding), - ) - } + PlatformText( + text = description, + modifier = + Modifier + .background(PlatformTheme.colorScheme.card) + .fillMaxWidth() + .padding(bottom = 8.dp) + .padding(horizontal = screenHorizontalPadding), + ) } } else { PlatformSegmentedListItem( modifier = modifier, index = index, totalCount = totalCount, - onClick = { - onClicked?.invoke(item) + onClick = { onClicked?.invoke() }, + headlineContent = { PlatformText(text = item.title) }, + leadingContent = { + if (item.avatar != null) { + NetworkImage( + model = item.avatar, + contentDescription = item.title, + modifier = + Modifier + .size(AvatarComponentDefaults.size) + .clip(PlatformTheme.shapes.medium), + ) + } else { + FAIcon( + imageVector = FontAwesomeIcons.Solid.List, + contentDescription = null, + modifier = + Modifier + .size(AvatarComponentDefaults.size) + .background( + color = PlatformTheme.colorScheme.primaryContainer, + shape = PlatformTheme.shapes.medium, + ).padding(8.dp), + tint = PlatformTheme.colorScheme.onPrimaryContainer, + ) + } }, - headlineContent = { - PlatformText(text = item.title) + supportingContent = { + if (item.creator != null) { + PlatformText( + text = + stringResource( + Res.string.feeds_discover_feeds_created_by, + item.creator?.handle ?: "Unknown", + ), + style = PlatformTheme.typography.caption, + color = PlatformTheme.colorScheme.caption, + ) + } + }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + trailingContent.invoke(this) + } }, + ) + } +} + +@Composable +private fun UiFeedCard( + item: UiList.Feed, + onClicked: (() -> Unit)?, + trailingContent: @Composable (RowScope.() -> Unit), + index: Int, + totalCount: Int, + modifier: Modifier = Modifier, +) { + val description = item.description + if (!description.isNullOrEmpty()) { + Column( + modifier = + modifier + .listCard( + index = index, + totalCount = totalCount, + ).background(PlatformTheme.colorScheme.card) + .let { + if (onClicked == null) { + it + } else { + it.clickable { onClicked() } + } + }, + ) { + PlatformListItem( + headlineContent = { PlatformText(text = item.title) }, + leadingContent = { + if (item.avatar != null) { + NetworkImage( + model = item.avatar, + contentDescription = item.title, + modifier = + Modifier + .size(AvatarComponentDefaults.size) + .clip(PlatformTheme.shapes.medium), + ) + } else { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Rss, + contentDescription = null, + modifier = + Modifier + .size(AvatarComponentDefaults.size) + .background( + color = PlatformTheme.colorScheme.primaryContainer, + shape = PlatformTheme.shapes.medium, + ).padding(8.dp), + tint = PlatformTheme.colorScheme.onPrimaryContainer, + ) + } + }, + supportingContent = { + if (item.creator != null) { + PlatformText( + text = + stringResource( + Res.string.feeds_discover_feeds_created_by, + item.creator?.handle ?: "Unknown", + ), + style = PlatformTheme.typography.caption, + color = PlatformTheme.colorScheme.caption, + ) + } + }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + trailingContent.invoke(this) + } + }, + ) + PlatformText( + text = description, + modifier = + Modifier + .background(PlatformTheme.colorScheme.card) + .fillMaxWidth() + .padding(bottom = 8.dp) + .padding(horizontal = screenHorizontalPadding), + ) + } + } else { + PlatformSegmentedListItem( + modifier = modifier, + index = index, + totalCount = totalCount, + onClick = { onClicked?.invoke() }, + headlineContent = { PlatformText(text = item.title) }, leadingContent = { if (item.avatar != null) { NetworkImage( @@ -243,10 +417,155 @@ public fun UiListItem( } }, trailingContent = { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - trailingContent.invoke(this, item) + Row(verticalAlignment = Alignment.CenterVertically) { + trailingContent.invoke(this) + } + }, + ) + } +} + +@Composable +private fun UiAntennaCard( + item: UiList.Antenna, + onClicked: (() -> Unit)?, + trailingContent: @Composable (RowScope.() -> Unit), + index: Int, + totalCount: Int, + modifier: Modifier = Modifier, +) { + PlatformSegmentedListItem( + modifier = modifier, + index = index, + totalCount = totalCount, + onClick = { onClicked?.invoke() }, + headlineContent = { PlatformText(text = item.title) }, + leadingContent = { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Rss, + contentDescription = null, + modifier = + Modifier + .size(AvatarComponentDefaults.size) + .background( + color = PlatformTheme.colorScheme.primaryContainer, + shape = PlatformTheme.shapes.medium, + ).padding(8.dp), + tint = PlatformTheme.colorScheme.onPrimaryContainer, + ) + }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + trailingContent.invoke(this) + } + }, + ) +} + +@Composable +private fun UiChannelCard( + item: UiList.Channel, + onClicked: (() -> Unit)?, + trailingContent: @Composable (RowScope.() -> Unit), + index: Int, + totalCount: Int, + modifier: Modifier = Modifier, +) { + val description = item.description + if (description != null) { + Column( + modifier = + modifier + .listCard( + index = index, + totalCount = totalCount, + ).background(PlatformTheme.colorScheme.card) + .let { + if (onClicked == null) { + it + } else { + it.clickable { onClicked() } + } + }, + ) { + PlatformListItem( + headlineContent = { PlatformText(text = item.title) }, + leadingContent = { + if (item.banner != null) { + NetworkImage( + model = item.banner, + contentDescription = item.title, + modifier = + Modifier + .size(AvatarComponentDefaults.size) + .clip(PlatformTheme.shapes.medium), + ) + } else { + FAIcon( + imageVector = FontAwesomeIcons.Solid.List, + contentDescription = null, + modifier = + Modifier + .size(AvatarComponentDefaults.size) + .background( + color = PlatformTheme.colorScheme.primaryContainer, + shape = PlatformTheme.shapes.medium, + ).padding(8.dp), + tint = PlatformTheme.colorScheme.onPrimaryContainer, + ) + } + }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + trailingContent.invoke(this) + } + }, + ) + RichText( + text = description, + modifier = + Modifier + .background(PlatformTheme.colorScheme.card) + .fillMaxWidth() + .padding(bottom = 8.dp) + .padding(horizontal = screenHorizontalPadding), + ) + } + } else { + PlatformSegmentedListItem( + modifier = modifier, + index = index, + totalCount = totalCount, + onClick = { onClicked?.invoke() }, + headlineContent = { PlatformText(text = item.title) }, + leadingContent = { + if (item.banner != null) { + NetworkImage( + model = item.banner, + contentDescription = item.title, + modifier = + Modifier + .size(AvatarComponentDefaults.size) + .clip(PlatformTheme.shapes.medium), + ) + } else { + FAIcon( + imageVector = FontAwesomeIcons.Solid.List, + contentDescription = null, + modifier = + Modifier + .size(AvatarComponentDefaults.size) + .background( + color = PlatformTheme.colorScheme.primaryContainer, + shape = PlatformTheme.shapes.medium, + ).padding(8.dp), + tint = PlatformTheme.colorScheme.onPrimaryContainer, + ) + } + }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + trailingContent.invoke(this) } }, ) diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DMItem.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DMItem.kt index ea3d4f282..ff85c59ef 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DMItem.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DMItem.kt @@ -36,7 +36,7 @@ import dev.dimension.flare.ui.component.status.CommonStatusComponent import dev.dimension.flare.ui.component.status.MediaItem import dev.dimension.flare.ui.model.UiDMItem import dev.dimension.flare.ui.model.UiMedia -import dev.dimension.flare.ui.model.UiUserV2 +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.route.DeeplinkRoute import dev.dimension.flare.ui.route.toUri import dev.dimension.flare.ui.theme.PlatformTheme @@ -46,7 +46,7 @@ import org.jetbrains.compose.resources.stringResource public fun DMItem( item: UiDMItem, onRetry: () -> Unit, - onUserClicked: (UiUserV2) -> Unit, + onUserClicked: (UiProfile) -> Unit, modifier: Modifier = Modifier, ) { val uriHandler = LocalUriHandler.current 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 6f19e3fd5..8f1a1a8b4 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 @@ -76,11 +76,13 @@ import compose.icons.fontawesomeicons.solid.Reply import compose.icons.fontawesomeicons.solid.Retweet import compose.icons.fontawesomeicons.solid.ShareNodes import compose.icons.fontawesomeicons.solid.Trash +import compose.icons.fontawesomeicons.solid.Tv import compose.icons.fontawesomeicons.solid.UserSlash import compose.icons.fontawesomeicons.solid.VolumeXmark import dev.dimension.flare.compose.ui.Res import dev.dimension.flare.compose.ui.bookmark_add import dev.dimension.flare.compose.ui.bookmark_remove +import dev.dimension.flare.compose.ui.channel_title import dev.dimension.flare.compose.ui.comment import dev.dimension.flare.compose.ui.delete import dev.dimension.flare.compose.ui.fx_share @@ -393,17 +395,19 @@ public fun CommonStatusComponent( ) } - when (val content = item.bottomContent) { - is UiTimeline.ItemContent.Status.BottomContent.Reaction -> { - if (content.emojiReactions.isNotEmpty()) { - Spacer(modifier = Modifier.height(4.dp)) - StatusReactionComponent( - data = content, - ) + 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 + null -> Unit + } } if (isDetail) { @@ -559,55 +563,82 @@ private fun StatusReactionComponent( data: UiTimeline.ItemContent.Status.BottomContent.Reaction, modifier: Modifier = Modifier, ) { - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, + Column( modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp), ) { - items(data.emojiReactions) { reaction -> - val color = - if (reaction.me) { - PlatformTheme.colorScheme.primaryContainer - } else { - PlatformTheme.colorScheme.cardAlt - } - val borderColor = - if (reaction.me) { - PlatformTheme.colorScheme.primary - } else { - Color.Transparent - } - PlatformCard( - shape = RoundedCornerShape(100), - containerColor = color, - modifier = - Modifier - .border( - FlareDividerDefaults.thickness, - color = borderColor, - shape = RoundedCornerShape(100), - ), + data.channel?.let { channel -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - Row( - verticalAlignment = Alignment.CenterVertically, + FAIcon( + imageVector = FontAwesomeIcons.Solid.Tv, + contentDescription = channel.name, + tint = PlatformTheme.colorScheme.caption, modifier = Modifier - .clickable { - reaction.onClicked.invoke() - }.padding(horizontal = 8.dp, vertical = 4.dp), - ) { - if (reaction.isUnicode) { - PlatformText(reaction.name) - } else { - EmojiImage( - uri = reaction.url, - modifier = Modifier.height(16.dp), - ) + .size(12.dp), + ) + PlatformText( + text = channel.name, + style = PlatformTheme.typography.caption, + color = PlatformTheme.colorScheme.caption, + maxLines = 1, + ) + } + } + if (data.emojiReactions.isNotEmpty()) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + items(data.emojiReactions) { reaction -> + val color = + if (reaction.me) { + PlatformTheme.colorScheme.primaryContainer + } else { + PlatformTheme.colorScheme.cardAlt + } + val borderColor = + if (reaction.me) { + PlatformTheme.colorScheme.primary + } else { + Color.Transparent + } + PlatformCard( + shape = RoundedCornerShape(100), + containerColor = color, + modifier = + Modifier + .border( + FlareDividerDefaults.thickness, + color = borderColor, + shape = RoundedCornerShape(100), + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .clickable { + reaction.onClicked.invoke() + }.padding(horizontal = 8.dp, vertical = 4.dp), + ) { + if (reaction.isUnicode) { + PlatformText(reaction.name) + } else { + EmojiImage( + uri = reaction.url, + modifier = Modifier.height(16.dp), + ) + } + Spacer(modifier = Modifier.width(4.dp)) + PlatformText( + text = reaction.count.humanized, + ) + } } - Spacer(modifier = Modifier.width(4.dp)) - PlatformText( - text = reaction.count.humanized, - ) } } } @@ -758,6 +789,14 @@ public fun StatusVisibilityComponent( modifier = modifier, tint = tint, ) + + UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Channel -> + FAIcon( + imageVector = FontAwesomeIcons.Solid.Tv, + contentDescription = stringResource(resource = Res.string.channel_title), + modifier = modifier, + tint = tint, + ) } } 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 fc11b1936..4d2c7173c 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 @@ -20,12 +20,12 @@ import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.component.AvatarComponent import dev.dimension.flare.ui.component.RichText import dev.dimension.flare.ui.component.platform.PlatformText -import dev.dimension.flare.ui.model.UiUserV2 +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.theme.PlatformTheme @Composable public fun CommonStatusHeaderComponent( - data: UiUserV2, + data: UiProfile, onUserClick: (MicroBlogKey) -> Unit, modifier: Modifier = Modifier, leadingContent: @Composable (RowScope.() -> Unit)? = { 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 3c71b8266..5bb744168 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,14 +18,11 @@ 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.platform.LocalUriHandler import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid -import compose.icons.fontawesomeicons.solid.CircleExclamation import compose.icons.fontawesomeicons.solid.File -import compose.icons.fontawesomeicons.solid.FileCircleExclamation import dev.dimension.flare.common.PagingState import dev.dimension.flare.common.onEmpty import dev.dimension.flare.common.onEndOfList @@ -33,19 +30,14 @@ import dev.dimension.flare.common.onError import dev.dimension.flare.common.onLoading import dev.dimension.flare.common.onSuccess import dev.dimension.flare.compose.ui.Res -import dev.dimension.flare.compose.ui.login_expired -import dev.dimension.flare.compose.ui.login_expired_message import dev.dimension.flare.compose.ui.status_empty import dev.dimension.flare.compose.ui.status_loadmore_end -import dev.dimension.flare.compose.ui.status_loadmore_error -import dev.dimension.flare.data.repository.LoginExpiredException import dev.dimension.flare.model.MicroBlogKey +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.route.DeeplinkRoute -import dev.dimension.flare.ui.route.toUri import dev.dimension.flare.ui.theme.PlatformTheme import dev.dimension.flare.ui.theme.screenHorizontalPadding import org.jetbrains.compose.resources.stringResource @@ -221,64 +213,11 @@ private fun OnError( onRetry: () -> Unit, modifier: Modifier = Modifier, ) { - when (error) { - is LoginExpiredException -> { - LoginExpiredError(error, modifier) - } - - else -> { - Column( - modifier = - modifier - .clickable { - onRetry.invoke() - }.fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.FileCircleExclamation, - contentDescription = null, - modifier = Modifier.size(48.dp), - ) - PlatformText(text = stringResource(Res.string.status_loadmore_error)) - error.message?.let { PlatformText(text = it) } - } - } - } -} - -@Composable -private fun LoginExpiredError( - exception: LoginExpiredException, - modifier: Modifier = Modifier, -) { - val uriHandler = LocalUriHandler.current - Column( - modifier = - modifier - .clickable { - uriHandler.openUri(DeeplinkRoute.Login.toUri()) - }, - verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.CircleExclamation, - contentDescription = null, - modifier = Modifier.size(48.dp), - ) - PlatformText( - text = stringResource(resource = Res.string.login_expired), - ) - PlatformText( - text = stringResource(resource = Res.string.login_expired_message), - ) - PlatformText( - text = exception.accountKey.toString(), - ) - } + ErrorContent( + error = error, + onRetry = onRetry, + modifier = modifier, + ) } @Composable diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusRetweetHeaderComponent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusRetweetHeaderComponent.kt index bdbafdeac..8a0a6aad3 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusRetweetHeaderComponent.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusRetweetHeaderComponent.kt @@ -14,14 +14,14 @@ import androidx.compose.ui.unit.dp import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.RichText import dev.dimension.flare.ui.component.platform.PlatformText -import dev.dimension.flare.ui.model.UiUserV2 +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.theme.PlatformContentColor import dev.dimension.flare.ui.theme.PlatformTheme @Composable internal fun StatusRetweetHeaderComponent( icon: ImageVector, - user: UiUserV2?, + user: UiProfile?, text: String, modifier: Modifier = Modifier, textStyle: TextStyle = PlatformTheme.typography.caption, 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 728f6e13f..9decec3db 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 @@ -14,12 +14,12 @@ import dev.dimension.flare.ui.component.AvatarComponent import dev.dimension.flare.ui.component.AvatarComponentDefaults import dev.dimension.flare.ui.component.RichText import dev.dimension.flare.ui.component.platform.PlatformText -import dev.dimension.flare.ui.model.UiUserV2 +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.theme.PlatformTheme @Composable internal fun UserCompat( - user: UiUserV2, + user: UiProfile, modifier: Modifier = Modifier, onUserClick: (MicroBlogKey) -> Unit = {}, leading: @Composable (RowScope.() -> Unit)? = { diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiListExt.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiListExt.kt index 142c94555..67393f621 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiListExt.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiListExt.kt @@ -1,62 +1,64 @@ package dev.dimension.flare.ui.model -import dev.dimension.flare.data.model.Bluesky.FeedTabItem +import dev.dimension.flare.data.model.Bluesky import dev.dimension.flare.data.model.IconType -import dev.dimension.flare.data.model.IconType.Mixed import dev.dimension.flare.data.model.ListTimelineTabItem import dev.dimension.flare.data.model.Misskey import dev.dimension.flare.data.model.TabItem import dev.dimension.flare.data.model.TabMetaData import dev.dimension.flare.data.model.TitleType import dev.dimension.flare.model.AccountType -import dev.dimension.flare.model.AccountType.Specific import dev.dimension.flare.model.MicroBlogKey public fun UiList.toTabItem(accountKey: MicroBlogKey): TabItem = - when (type) { - UiList.Type.Feed -> { - FeedTabItem( - account = Specific(accountKey), - uri = id, + when (this) { + is UiList.List -> + ListTimelineTabItem( + account = AccountType.Specific(accountKey), + listId = id, metaData = TabMetaData( title = TitleType.Text(title), icon = - Mixed( - icon = IconType.Material.MaterialIcon.List, - userKey = accountKey, - ), + avatar?.let { + IconType.Url(it) + } ?: IconType.Material(IconType.Material.MaterialIcon.List), ), ) - } - UiList.Type.List -> - ListTimelineTabItem( + is UiList.Feed -> + Bluesky.FeedTabItem( account = AccountType.Specific(accountKey), - listId = id, + uri = id, metaData = TabMetaData( title = TitleType.Text(title), icon = - IconType.Mixed( - icon = IconType.Material.MaterialIcon.List, - userKey = accountKey, - ), + avatar?.let { + IconType.Url(it) + } ?: IconType.Material(IconType.Material.MaterialIcon.Feeds), ), ) - UiList.Type.Antenna -> + is UiList.Antenna -> Misskey.AntennasTimelineTabItem( account = AccountType.Specific(accountKey), antennasId = id, metaData = TabMetaData( title = TitleType.Text(title), - icon = - IconType.Mixed( - icon = IconType.Material.MaterialIcon.Rss, - userKey = accountKey, - ), + icon = IconType.Material(IconType.Material.MaterialIcon.List), + ), + ) + + is UiList.Channel -> + Misskey.ChannelTimelineTabItem( + account = AccountType.Specific(accountKey), + channelId = id, + metaData = + TabMetaData( + title = TitleType.Text(title), + icon = IconType.Material(IconType.Material.MaterialIcon.List), ), ) } diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedsWithTabsPresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedsWithTabsPresenter.kt index 59f53a1b1..f62711fc3 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedsWithTabsPresenter.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedsWithTabsPresenter.kt @@ -35,7 +35,7 @@ public class BlueskyFeedsWithTabsPresenter( TabMetaData( title = TitleType.Text(item.title), icon = - item.avatar?.let { + item.let { it as? UiList.Feed }?.avatar?.let { IconType.Url(it) } ?: IconType.Material(IconType.Material.MaterialIcon.Feeds), ), diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/list/AllListWithTabsPresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/list/AllListWithTabsPresenter.kt index e9b2006d6..6d2721ac5 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/list/AllListWithTabsPresenter.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/list/AllListWithTabsPresenter.kt @@ -32,7 +32,7 @@ public class AllListWithTabsPresenter( TabMetaData( title = TitleType.Text(item.title), icon = - item.avatar?.let { + item.let { it as? UiList.List }?.avatar?.let { IconType.Url(it) } ?: IconType.Material(IconType.Material.MaterialIcon.List), ), diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index 67710ec7d..6744f437d 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -364,4 +364,11 @@ Failed to import data Confirm Import This will import data from the file. Existing records with matching IDs will be replaced. Do you want to continue? + + Channel + + Following + Favorites + Owned + Featured diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt index c47cf5af4..0a33048ea 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt @@ -428,6 +428,7 @@ private fun getRoute(tab: TabItem): Route = is DirectMessageTabItem -> Route.DmList(tab.account) is RssTabItem -> Route.RssList is Misskey.AntennasListTabItem -> Route.MisskeyAntennas(tab.account) + is Misskey.ChannelListTabItem -> Route.MisskeyChannelList(tab.account) } @Composable 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 a7ee57195..05855f3e6 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 @@ -16,8 +16,8 @@ import dev.dimension.flare.login_expired import dev.dimension.flare.login_expired_relogin import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.component.placeholder +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiState -import dev.dimension.flare.ui.model.UiUserV2 import dev.dimension.flare.ui.model.onError import dev.dimension.flare.ui.model.onLoading import dev.dimension.flare.ui.model.onSuccess @@ -27,16 +27,16 @@ import io.github.composefluent.component.Text import org.jetbrains.compose.resources.stringResource @Composable -fun AccountItem( +fun AccountItem( userState: UiState, onClick: (MicroBlogKey) -> Unit, toLogin: () -> Unit, modifier: Modifier = Modifier, - trailingContent: @Composable (UiUserV2?) -> Unit = { }, - headlineContent: @Composable (UiUserV2) -> Unit = { + trailingContent: @Composable (UiProfile?) -> Unit = { }, + headlineContent: @Composable (UiProfile) -> Unit = { RichText(text = it.name, maxLines = 1) }, - supportingContent: @Composable (UiUserV2) -> Unit = { + supportingContent: @Composable (UiProfile) -> Unit = { Text(text = it.handle, 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 fae47be1d..ca866a9ed 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 @@ -199,6 +199,16 @@ internal sealed interface Route : NavKey { val accountType: AccountType, ) : ScreenRoute + data class MisskeyChannelList( + val accountType: AccountType, + ) : ScreenRoute + + data class MisskeyChannelTimeline( + val accountType: AccountType, + val channelId: String, + val title: String, + ) : ScreenRoute + data object TabSetting : ScreenRoute data class TabGroupConfig( 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 8737df3bf..6368db6c3 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 @@ -69,6 +69,7 @@ import dev.dimension.flare.ui.screen.list.AllListScreen import dev.dimension.flare.ui.screen.media.RawMediaScreen import dev.dimension.flare.ui.screen.media.StatusMediaScreen import dev.dimension.flare.ui.screen.misskey.AntennasListScreen +import dev.dimension.flare.ui.screen.misskey.ChannelListScreen import dev.dimension.flare.ui.screen.rss.EditRssSourceScreen import dev.dimension.flare.ui.screen.rss.ImportOPMLScreen import dev.dimension.flare.ui.screen.rss.RssListScreen @@ -832,6 +833,48 @@ internal fun WindowScope.Router( onBack = onBack, ) } + entry( + metadata = + ListDetailSceneStrategy.listPane( + sceneKey = "misskey_channels_list", + detailPlaceholder = { + Box( + modifier = + Modifier + .fillMaxSize() + .background(FluentTheme.colors.background.solid.base), + ) + }, + ), + ) { args -> + ChannelListScreen( + accountType = args.accountType, + toTimeline = { + navigate(Route.MisskeyChannelTimeline(args.accountType, it.id, it.title)) + }, + ) + } + entry( + metadata = + ListDetailSceneStrategy.detailPane( + sceneKey = "misskey_channels_list", + ), + ) { args -> + TimelineScreen( + tabItem = + remember(args) { + Misskey.ChannelTimelineTabItem( + channelId = args.channelId, + account = args.accountType, + metaData = + TabMetaData( + title = TitleType.Text(args.title), + icon = IconType.Material(IconType.Material.MaterialIcon.Channel), + ), + ) + }, + ) + } }, ) } 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 9ebec8549..b6f365116 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 @@ -1267,6 +1267,9 @@ internal val UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.localNa UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Specified -> Res.string.misskey_visibility_specified + + UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Channel -> + Res.string.misskey_visibility_public } internal val UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.localDescription: StringResource @@ -1283,4 +1286,7 @@ internal val UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.localDe UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Specified -> Res.string.misskey_visibility_specified_description + + UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Channel -> + Res.string.misskey_visibility_public_description } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt index 154d4ade2..3e3842ddf 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt @@ -52,6 +52,7 @@ internal fun FeedListScreen( adapter = scrollbarAdapter, ) { LazyColumn( + state = listState, contentPadding = PaddingValues( vertical = 8.dp, 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 7fccfd79e..fa8580b2a 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 @@ -33,6 +33,7 @@ import compose.icons.fontawesomeicons.solid.Plus import dev.dimension.flare.Res import dev.dimension.flare.add_rss_source import dev.dimension.flare.antenna_title +import dev.dimension.flare.channel_title import dev.dimension.flare.data.model.TabItem import dev.dimension.flare.ok import dev.dimension.flare.rss_title @@ -226,6 +227,9 @@ internal fun AddTabDialog( is PinnableTimelineTabPresenter.State.Tab.Antenna -> Res.string.antenna_title + + is PinnableTimelineTabPresenter.State.Tab.Channel -> + Res.string.channel_title } }.map { stringResource(it) } LiteFilter( diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/UserListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/UserListScreen.kt index 08ffa8003..21f471ef3 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/UserListScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/UserListScreen.kt @@ -15,8 +15,8 @@ import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.common.itemsIndexed import dev.dimension.flare.ui.component.AccountItem +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiState -import dev.dimension.flare.ui.model.UiUserV2 import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.profile.FansPresenter import dev.dimension.flare.ui.presenter.profile.FollowingPresenter @@ -85,7 +85,7 @@ internal fun FansScreen( @Composable private fun UserListScreen( - data: PagingState, + data: PagingState, onUserClick: (MicroBlogKey) -> Unit, ) { LazyColumn( diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/misskey/ChannelListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/misskey/ChannelListScreen.kt new file mode 100644 index 000000000..857b6ce6e --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/misskey/ChannelListScreen.kt @@ -0,0 +1,122 @@ +package dev.dimension.flare.ui.screen.misskey + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.dimension.flare.LocalWindowPadding +import dev.dimension.flare.RegisterTabCallback +import dev.dimension.flare.Res +import dev.dimension.flare.common.isRefreshing +import dev.dimension.flare.misskey_channel_tab_favorites +import dev.dimension.flare.misskey_channel_tab_featured +import dev.dimension.flare.misskey_channel_tab_following +import dev.dimension.flare.misskey_channel_tab_owned +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.ui.common.plus +import dev.dimension.flare.ui.component.uiListItemComponent +import dev.dimension.flare.ui.model.UiList +import dev.dimension.flare.ui.presenter.home.misskey.MisskeyChannelListPresenter +import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.theme.screenHorizontalPadding +import io.github.composefluent.component.LiteFilter +import io.github.composefluent.component.PillButton +import io.github.composefluent.component.ProgressBar +import io.github.composefluent.component.ScrollbarContainer +import io.github.composefluent.component.Text +import moe.tlaster.precompose.molecule.producePresenter +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun ChannelListScreen( + accountType: AccountType, + toTimeline: (UiList) -> Unit, +) { + val state by producePresenter { + remember { + MisskeyChannelListPresenter(accountType) + }.invoke() + } + + val listState = rememberLazyListState() + val scrollbarAdapter = rememberScrollbarAdapter(listState) + RegisterTabCallback(listState, onRefresh = { state.refresh() }) + + Box { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .padding(LocalWindowPadding.current), + ) { + LiteFilter( + modifier = Modifier.padding(horizontal = screenHorizontalPadding), + ) { + state.allTypes.forEach { type -> + PillButton( + selected = state.type == type, + onSelectedChanged = { + state.setType(type) + }, + ) { + Text( + text = + stringResource( + when (type) { + MisskeyChannelListPresenter.State.Type.Following -> + Res.string.misskey_channel_tab_following + MisskeyChannelListPresenter.State.Type.Favorites -> + Res.string.misskey_channel_tab_favorites + MisskeyChannelListPresenter.State.Type.Owned -> + Res.string.misskey_channel_tab_owned + MisskeyChannelListPresenter.State.Type.Featured -> + Res.string.misskey_channel_tab_featured + }, + ), + ) + } + } + } + ScrollbarContainer( + adapter = scrollbarAdapter, + ) { + LazyColumn( + state = listState, + contentPadding = + PaddingValues( + horizontal = screenHorizontalPadding, + ), + modifier = + Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + uiListItemComponent( + items = state.data, + onClicked = toTimeline, + ) + } + } + } + if (state.data.isRefreshing) { + ProgressBar( + modifier = + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth(), + ) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0cb3ba1a8..f1f3b40a0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -182,7 +182,7 @@ material3-navigation = { group = "io.github.stefanoq21", name = "material3-navig compose-lint-checks = { group = "com.slack.lint.compose", name = "compose-lint-checks", version.ref = "compose-lint-checks" } -mfm-multiplatform = "moe.tlaster:mfm-multiplatform:0.2.2" +mfm-multiplatform = "moe.tlaster:mfm-multiplatform:0.2.3" compose-webview = { group = "io.github.kevinnzou", name = "compose-webview", version = "0.33.6" } compose-webview-multiplatform = { group = "io.github.kevinnzou", name = "compose-webview-multiplatform", version = "2.0.3" } diff --git a/iosApp/flare/Assets.xcassets/fa-heart-circle-minus.symbolset/Contents.json b/iosApp/flare/Assets.xcassets/fa-heart-circle-minus.symbolset/Contents.json new file mode 100644 index 000000000..17203bfcb --- /dev/null +++ b/iosApp/flare/Assets.xcassets/fa-heart-circle-minus.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "fa-heart-circle-minus.svg", + "idiom" : "universal" + } + ] +} diff --git a/iosApp/flare/Assets.xcassets/fa-heart-circle-minus.symbolset/fa-heart-circle-minus.svg b/iosApp/flare/Assets.xcassets/fa-heart-circle-minus.symbolset/fa-heart-circle-minus.svg new file mode 100644 index 000000000..a643d3093 --- /dev/null +++ b/iosApp/flare/Assets.xcassets/fa-heart-circle-minus.symbolset/fa-heart-circle-minus.svg @@ -0,0 +1,84 @@ + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.6.0 + Requires Xcode 16 or greater + Generated from square + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/iosApp/flare/Assets.xcassets/fa-heart-circle-plus.symbolset/Contents.json b/iosApp/flare/Assets.xcassets/fa-heart-circle-plus.symbolset/Contents.json new file mode 100644 index 000000000..22e9f8813 --- /dev/null +++ b/iosApp/flare/Assets.xcassets/fa-heart-circle-plus.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "fa-heart-circle-plus.svg", + "idiom" : "universal" + } + ] +} diff --git a/iosApp/flare/Assets.xcassets/fa-heart-circle-plus.symbolset/fa-heart-circle-plus.svg b/iosApp/flare/Assets.xcassets/fa-heart-circle-plus.symbolset/fa-heart-circle-plus.svg new file mode 100644 index 000000000..9ab747d52 --- /dev/null +++ b/iosApp/flare/Assets.xcassets/fa-heart-circle-plus.symbolset/fa-heart-circle-plus.svg @@ -0,0 +1,84 @@ + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.6.0 + Requires Xcode 16 or greater + Generated from square + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/iosApp/flare/Assets.xcassets/fa-tv.symbolset/Contents.json b/iosApp/flare/Assets.xcassets/fa-tv.symbolset/Contents.json new file mode 100644 index 000000000..7c8182106 --- /dev/null +++ b/iosApp/flare/Assets.xcassets/fa-tv.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "fa-tv.svg", + "idiom" : "universal" + } + ] +} diff --git a/iosApp/flare/Assets.xcassets/fa-tv.symbolset/fa-tv.svg b/iosApp/flare/Assets.xcassets/fa-tv.symbolset/fa-tv.svg new file mode 100644 index 000000000..e0177a3ee --- /dev/null +++ b/iosApp/flare/Assets.xcassets/fa-tv.symbolset/fa-tv.svg @@ -0,0 +1,84 @@ + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.6.0 + Requires Xcode 16 or greater + Generated from square + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/iosApp/flare/Localizable.xcstrings b/iosApp/flare/Localizable.xcstrings index da4a6192b..1b9e3ae66 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" : { @@ -190,13 +190,13 @@ "value" : "%lld" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "%lld" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "%lld" @@ -314,13 +314,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 +438,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 +598,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 +788,16 @@ "value" : "정보" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Informatie" + "value" : "Om" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Om" + "value" : "Informatie" } }, "pl" : { @@ -942,16 +942,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 +1078,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 +1208,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 +1380,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 +1570,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 +1730,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" : { @@ -1902,16 +1902,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" : { @@ -2062,16 +2062,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" : { @@ -2228,16 +2228,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" : { @@ -2418,16 +2418,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" : { @@ -2608,16 +2608,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" : { @@ -2798,16 +2798,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" : { @@ -2988,16 +2988,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" : { @@ -3178,16 +3178,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" : { @@ -3368,16 +3368,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" : { @@ -3528,16 +3528,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" : { @@ -3694,16 +3694,16 @@ "value" : "목록" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Klantenlijst" + "value" : "Liste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liste" + "value" : "Klantenlijst" } }, "pl" : { @@ -3848,16 +3848,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" : { @@ -3972,13 +3972,13 @@ "value" : "ALT" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "ALT" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "ALT" @@ -4138,13 +4138,13 @@ "value" : "안테나" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Antenne" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Antenne" @@ -4328,13 +4328,13 @@ "value" : "안테나" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Antenne" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Antenne" @@ -4512,16 +4512,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" : { @@ -4696,16 +4696,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" : { @@ -4850,16 +4850,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" : { @@ -4980,16 +4980,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" : { @@ -5146,16 +5146,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" : { @@ -5336,16 +5336,16 @@ "value" : "둥글게" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ronde" + "value" : "Rund" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rund" + "value" : "Ronde" } }, "pl" : { @@ -5526,16 +5526,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" : { @@ -5716,16 +5716,16 @@ "value" : "정사각형" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vierkant" + "value" : "Firkant" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Firkant" + "value" : "Vierkant" } }, "pl" : { @@ -5900,16 +5900,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" : { @@ -6054,16 +6054,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" : { @@ -6220,16 +6220,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" : { @@ -6410,16 +6410,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" : { @@ -6594,16 +6594,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" : { @@ -6748,13 +6748,13 @@ "value" : "Font Size" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Font Size" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Font Size" @@ -6884,16 +6884,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" : { @@ -7014,16 +7014,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" : { @@ -7144,16 +7144,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" : { @@ -7274,16 +7274,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" : { @@ -7404,13 +7404,13 @@ "value" : "Hidden" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Hidden" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Hidden" @@ -7534,16 +7534,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" : { @@ -7664,16 +7664,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" : { @@ -7794,16 +7794,16 @@ "value" : "ストレッチ" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Uitrekken" + "value" : "Strekk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Strekk" + "value" : "Uitrekken" } }, "pl" : { @@ -7960,16 +7960,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" : { @@ -8114,16 +8114,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" : { @@ -8280,16 +8280,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" : { @@ -8434,16 +8434,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" : { @@ -8600,16 +8600,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" : { @@ -8754,16 +8754,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" : { @@ -8884,16 +8884,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" : { @@ -9014,16 +9014,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" : { @@ -9180,16 +9180,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" : { @@ -9364,16 +9364,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" : { @@ -9554,16 +9554,16 @@ "value" : "테마" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Thema" + "value" : "Tema" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tema" + "value" : "Thema" } }, "pl" : { @@ -9744,16 +9744,16 @@ "value" : "어두운 색" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Donker" + "value" : "Mørk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Mørk" + "value" : "Donker" } }, "pl" : { @@ -9934,16 +9934,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" : { @@ -10124,16 +10124,16 @@ "value" : "밝은 색" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Licht" + "value" : "Lys" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lys" + "value" : "Licht" } }, "pl" : { @@ -10308,16 +10308,16 @@ "value" : "시스템" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Systeem" + "value" : "Systemadministrasjon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Systemadministrasjon" + "value" : "Systeem" } }, "pl" : { @@ -10498,16 +10498,16 @@ "value" : "모양" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Uiterlijk" + "value" : "Utseende" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Utseende" + "value" : "Uiterlijk" } }, "pl" : { @@ -10652,16 +10652,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" : { @@ -10782,16 +10782,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" : { @@ -10948,16 +10948,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" : { @@ -11138,16 +11138,16 @@ "value" : "항상" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "altijd" + "value" : "Alltid" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Alltid" + "value" : "altijd" } }, "pl" : { @@ -11292,16 +11292,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" : { @@ -11458,16 +11458,16 @@ "value" : "절대" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Nooit" + "value" : "Aldri" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aldri" + "value" : "Nooit" } }, "pl" : { @@ -11648,16 +11648,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" : { @@ -11838,16 +11838,16 @@ "value" : "차단" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Blokkeren" + "value" : "Blokker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Blokker" + "value" : "Blokkeren" } }, "pl" : { @@ -11992,16 +11992,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" : { @@ -12122,16 +12122,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" : { @@ -12412,16 +12412,16 @@ "value" : "피드" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Feeds" + "value" : "Strøm" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Strøm" + "value" : "Feeds" } }, "pl" : { @@ -12602,16 +12602,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" : { @@ -12792,16 +12792,16 @@ "value" : "고정됨" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vastgezet" + "value" : "Festet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Festet" + "value" : "Vastgezet" } }, "pl" : { @@ -12982,16 +12982,16 @@ "value" : "좋아요를 눌렀습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "favoriet" + "value" : "favorisert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "favorisert" + "value" : "favoriet" } }, "pl" : { @@ -13172,16 +13172,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" : { @@ -13362,16 +13362,16 @@ "value" : "인용했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "geciteerd" + "value" : "sitert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "sitert" + "value" : "geciteerd" } }, "pl" : { @@ -13552,16 +13552,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" : { @@ -13736,16 +13736,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" : { @@ -13920,16 +13920,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" : { @@ -14104,16 +14104,16 @@ "value" : "알 수 없음" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Onbekend" + "value" : "Ukjent" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ukjent" + "value" : "Onbekend" } }, "pl" : { @@ -14294,16 +14294,16 @@ "value" : "신고하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporteren" + "value" : "Rapporter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporter" + "value" : "Rapporteren" } }, "pl" : { @@ -14478,16 +14478,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" : { @@ -14662,16 +14662,16 @@ "value" : "오해의 소지가 있는" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Misleidend" + "value" : "Villende" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Villende" + "value" : "Misleidend" } }, "pl" : { @@ -14846,16 +14846,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" : { @@ -15030,16 +15030,16 @@ "value" : "기타" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "anders" + "value" : "Annet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Annet" + "value" : "anders" } }, "pl" : { @@ -15214,16 +15214,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" : { @@ -15398,16 +15398,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" : { @@ -15582,16 +15582,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" : { @@ -15766,16 +15766,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" : { @@ -15950,16 +15950,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" : { @@ -16140,16 +16140,16 @@ "value" : "스팸" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Spam" + "value" : "Søppelpost" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Søppelpost" + "value" : "Spam" } }, "pl" : { @@ -16324,16 +16324,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" : { @@ -16508,16 +16508,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" : { @@ -16692,16 +16692,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" : { @@ -16882,16 +16882,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" : { @@ -17072,16 +17072,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" : { @@ -17262,16 +17262,16 @@ "value" : "취소" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "annuleren" + "value" : "Avbryt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Avbryt" + "value" : "annuleren" } }, "pl" : { @@ -17348,6 +17348,26 @@ } } }, + "channel_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Channel" + } + } + } + }, + "channels_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Channels" + } + } + } + }, "Close" : { "localizations" : { "af" : { @@ -17452,16 +17472,16 @@ "value" : "닫기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Afsluiten" + "value" : "Lukk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lukk" + "value" : "Afsluiten" } }, "pl" : { @@ -17642,16 +17662,16 @@ "value" : "댓글" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opmerking" + "value" : "Kommentar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kommentar" + "value" : "Opmerking" } }, "pl" : { @@ -17802,13 +17822,13 @@ "value" : "Cnacel" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Cnacel" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Cnacel" @@ -17968,16 +17988,16 @@ "value" : "보내기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verzenden" + "value" : "Sende" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sende" + "value" : "Verzenden" } }, "pl" : { @@ -18158,16 +18178,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" : { @@ -18348,16 +18368,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" : { @@ -18538,16 +18558,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" : { @@ -18692,16 +18712,16 @@ "value" : "Option" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Optie" + "value" : "Alternativ" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Alternativ" + "value" : "Optie" } }, "pl" : { @@ -18828,16 +18848,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" : { @@ -18958,16 +18978,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" : { @@ -19124,16 +19144,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" : { @@ -19314,16 +19334,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" : { @@ -19504,16 +19524,16 @@ "value" : "작성하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Samenstellen" + "value" : "Skriv" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Skriv" + "value" : "Samenstellen" } }, "pl" : { @@ -19694,16 +19714,16 @@ "value" : "인용하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Offerte" + "value" : "Sitat" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sitat" + "value" : "Offerte" } }, "pl" : { @@ -19884,16 +19904,16 @@ "value" : "답글" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beantwoorden" + "value" : "Svar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Svar" + "value" : "Beantwoorden" } }, "pl" : { @@ -20074,16 +20094,16 @@ "value" : "어두운 색" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Donker" + "value" : "Mørk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Mørk" + "value" : "Donker" } }, "pl" : { @@ -20264,16 +20284,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" : { @@ -20418,16 +20438,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" : { @@ -20584,16 +20604,16 @@ "value" : "삭제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijderen" + "value" : "Slett" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett" + "value" : "Verwijderen" } }, "pl" : { @@ -20774,16 +20794,16 @@ "value" : "삭제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijderen" + "value" : "Slett" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett" + "value" : "Verwijderen" } }, "pl" : { @@ -20964,16 +20984,16 @@ "value" : "삭제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijderen" + "value" : "Slett" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett" + "value" : "Verwijderen" } }, "pl" : { @@ -21118,16 +21138,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" : { @@ -21284,16 +21304,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" : { @@ -21468,16 +21488,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" : { @@ -21622,16 +21642,16 @@ "value" : "説明" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beschrijving" + "value" : "Beskrivelse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Beskrivelse" + "value" : "Beschrijving" } }, "pl" : { @@ -21758,16 +21778,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" : { @@ -21894,16 +21914,16 @@ "value" : "トレンド" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Populair" + "value" : "Populært" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Populært" + "value" : "Populair" } }, "pl" : { @@ -22066,13 +22086,13 @@ "value" : "트렌딩 해시태그" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Trending Hashtags" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Trending Hashtags" @@ -22256,16 +22276,16 @@ "value" : "발견하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ontdek" + "value" : "Oppdag" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Oppdag" + "value" : "Ontdek" } }, "pl" : { @@ -22446,16 +22466,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" : { @@ -22606,16 +22626,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" : { @@ -22742,16 +22762,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" : { @@ -22908,16 +22928,16 @@ "value" : "완료" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voltooid" + "value" : "Ferdig" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ferdig" + "value" : "Voltooid" } }, "pl" : { @@ -23098,16 +23118,16 @@ "value" : "완료" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Voltooid" + "value" : "Ferdig" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ferdig" + "value" : "Voltooid" } }, "pl" : { @@ -23288,16 +23308,16 @@ "value" : "편집" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bewerken" + "value" : "Rediger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger" + "value" : "Bewerken" } }, "pl" : { @@ -23442,16 +23462,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" : { @@ -23608,16 +23628,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" : { @@ -23792,16 +23812,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" : { @@ -23982,16 +24002,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" : { @@ -24136,16 +24156,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" : { @@ -24272,16 +24292,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" : { @@ -24408,16 +24428,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" : { @@ -24580,16 +24600,16 @@ "value" : "오류" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Foutmelding" + "value" : "Feil" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Feil" + "value" : "Foutmelding" } }, "pl" : { @@ -24770,16 +24790,16 @@ "value" : "오류" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Foutmelding" + "value" : "Feil" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Feil" + "value" : "Foutmelding" } }, "pl" : { @@ -24924,16 +24944,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" : { @@ -25048,16 +25068,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" : { @@ -25184,16 +25204,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" : { @@ -25320,16 +25340,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" : { @@ -25486,16 +25506,16 @@ "value" : "북마크" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bladwijzers" + "value" : "Bokmerker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Bokmerker" + "value" : "Bladwijzers" } }, "pl" : { @@ -25676,16 +25696,16 @@ "value" : "발견하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ontdek" + "value" : "Oppdag" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Oppdag" + "value" : "Ontdek" } }, "pl" : { @@ -25830,16 +25850,16 @@ "value" : "お気に入り" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Favoriete" + "value" : "Favoritt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Favoritt" + "value" : "Favoriete" } }, "pl" : { @@ -26002,16 +26022,16 @@ "value" : "추천" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Aanbevolen" + "value" : "Anbefalt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Anbefalt" + "value" : "Aanbevolen" } }, "pl" : { @@ -26192,16 +26212,16 @@ "value" : "피드" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Feeds" + "value" : "Strøm" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Strøm" + "value" : "Feeds" } }, "pl" : { @@ -26382,16 +26402,16 @@ "value" : "홈" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Startpagina" + "value" : "Hjem" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hjem" + "value" : "Startpagina" } }, "pl" : { @@ -26572,16 +26592,16 @@ "value" : "목록" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Klantenlijst" + "value" : "Liste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Liste" + "value" : "Klantenlijst" } }, "pl" : { @@ -26762,16 +26782,16 @@ "value" : "내 정보" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "IK" + "value" : "Meg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Meg" + "value" : "IK" } }, "pl" : { @@ -26916,16 +26936,16 @@ "value" : "Notification" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Notificatie" + "value" : "Varsling" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Varsling" + "value" : "Notificatie" } }, "pl" : { @@ -27052,16 +27072,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" : { @@ -27182,16 +27202,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" : { @@ -27312,16 +27332,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" : { @@ -27442,16 +27462,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" : { @@ -27608,16 +27628,16 @@ "value" : "밝은 색" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Licht" + "value" : "Lys" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lys" + "value" : "Licht" } }, "pl" : { @@ -27798,16 +27818,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" : { @@ -27952,16 +27972,16 @@ "value" : "いいね!" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Leukgevonden" + "value" : "Likte" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Likte" + "value" : "Leukgevonden" } }, "pl" : { @@ -28118,16 +28138,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" : { @@ -28272,16 +28292,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" : { @@ -28438,16 +28458,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" : { @@ -28628,16 +28648,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" : { @@ -28818,16 +28838,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" : { @@ -29008,16 +29028,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" : { @@ -29162,16 +29182,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" : { @@ -29328,16 +29348,16 @@ "value" : "구성원" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "leden" + "value" : "Medlemmer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Medlemmer" + "value" : "leden" } }, "pl" : { @@ -29518,16 +29538,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" : { @@ -29672,16 +29692,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" : { @@ -29838,16 +29858,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" : { @@ -30028,16 +30048,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" : { @@ -30218,16 +30238,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" : { @@ -30408,16 +30428,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" : { @@ -30598,16 +30618,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" : { @@ -30746,16 +30766,16 @@ "value" : "読み込み中..." } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Laden..." + "value" : "Laster..." } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Laster..." + "value" : "Laden..." } }, "pl" : { @@ -30918,16 +30938,16 @@ "value" : "삭제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijderen" + "value" : "Slett" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett" + "value" : "Verwijderen" } }, "pl" : { @@ -31108,16 +31128,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" : { @@ -31298,16 +31318,16 @@ "value" : "편집" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bewerken" + "value" : "Rediger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger" + "value" : "Bewerken" } }, "pl" : { @@ -31458,16 +31478,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" : { @@ -31624,13 +31644,13 @@ "value" : "키워드" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Keyword" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Keyword" @@ -31778,16 +31798,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" : { @@ -31914,16 +31934,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" : { @@ -32044,16 +32064,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" : { @@ -32174,16 +32194,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" : { @@ -32304,16 +32324,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" : { @@ -32470,16 +32490,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" : { @@ -32660,16 +32680,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" : { @@ -32802,16 +32822,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" : { @@ -32968,16 +32988,16 @@ "value" : "게시물" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Statuses" + "value" : "Innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg" + "value" : "Statuses" } }, "pl" : { @@ -33158,16 +33178,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" : { @@ -33348,16 +33368,16 @@ "value" : "사용자" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gebruikers" + "value" : "Brukere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Brukere" + "value" : "Gebruikers" } }, "pl" : { @@ -33538,16 +33558,16 @@ "value" : "로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Aanmelden" + "value" : "Innlogging" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlogging" + "value" : "Aanmelden" } }, "pl" : { @@ -33728,16 +33748,16 @@ "value" : "로그인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Aanmelden" + "value" : "Innlogging" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlogging" + "value" : "Aanmelden" } }, "pl" : { @@ -33882,16 +33902,16 @@ "value" : "ログアウト" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Afmelden" + "value" : "Logg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Logg" + "value" : "Afmelden" } }, "pl" : { @@ -34054,16 +34074,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" : { @@ -34244,16 +34264,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" : { @@ -34434,16 +34454,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" : { @@ -34624,16 +34644,16 @@ "value" : "좋아요를 눌렀습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "favoriet" + "value" : "favorisert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "favorisert" + "value" : "favoriet" } }, "pl" : { @@ -34814,16 +34834,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" : { @@ -34998,16 +35018,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" : { @@ -35188,16 +35208,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" : { @@ -35372,16 +35392,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" : { @@ -35562,16 +35582,16 @@ "value" : "리블로그했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "gedeeld" + "value" : "reblogget" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "reblogget" + "value" : "gedeeld" } }, "pl" : { @@ -35746,16 +35766,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" : { @@ -35930,16 +35950,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" : { @@ -36114,16 +36134,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" : { @@ -36304,16 +36324,16 @@ "value" : "신고하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporteren" + "value" : "Rapporter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporter" + "value" : "Rapporteren" } }, "pl" : { @@ -36464,16 +36484,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" : { @@ -36606,16 +36626,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" : { @@ -36772,16 +36792,16 @@ "value" : "팔로워" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgers" + "value" : "Følgere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følgere" + "value" : "Volgers" } }, "pl" : { @@ -36962,16 +36982,16 @@ "value" : "팔로잉" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgt" + "value" : "Følger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følger" + "value" : "Volgt" } }, "pl" : { @@ -37147,16 +37167,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" : { @@ -37332,13 +37352,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" @@ -37517,16 +37537,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" : { @@ -37702,16 +37722,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" : { @@ -37887,16 +37907,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" : { @@ -38072,16 +38092,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" : { @@ -38257,16 +38277,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" : { @@ -38442,13 +38462,13 @@ "value" : "🤯" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "🤯" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "🤯" @@ -38627,16 +38647,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" : { @@ -38818,16 +38838,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" : { @@ -39003,16 +39023,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" : { @@ -39194,16 +39214,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" : { @@ -39385,16 +39405,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" : { @@ -39576,16 +39596,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" : { @@ -39761,16 +39781,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" : { @@ -39946,16 +39966,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" : { @@ -40131,16 +40151,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" : { @@ -40316,16 +40336,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" : { @@ -40501,16 +40521,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" : { @@ -40686,16 +40706,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" : { @@ -40871,16 +40891,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" : { @@ -41062,16 +41082,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" : { @@ -41253,16 +41273,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" : { @@ -41444,16 +41464,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" : { @@ -41635,16 +41655,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" : { @@ -41826,16 +41846,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" : { @@ -42011,16 +42031,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" : { @@ -42202,16 +42222,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" : { @@ -42393,16 +42413,16 @@ "value" : "인기 있는" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Populair" + "value" : "Populær" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Populær" + "value" : "Populair" } }, "pl" : { @@ -42584,16 +42604,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" : { @@ -42769,16 +42789,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" : { @@ -42960,16 +42980,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" : { @@ -43151,16 +43171,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" : { @@ -43342,16 +43362,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" : { @@ -43533,16 +43553,16 @@ "value" : "인플루언서" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bevochtiger" + "value" : "Påvirkning" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Påvirkning" + "value" : "Bevochtiger" } }, "pl" : { @@ -43724,16 +43744,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" : { @@ -43909,16 +43929,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" : { @@ -44100,16 +44120,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" : { @@ -44285,16 +44305,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" : { @@ -44476,16 +44496,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" : { @@ -44667,16 +44687,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" : { @@ -44858,16 +44878,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" : { @@ -45049,16 +45069,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" : { @@ -45240,16 +45260,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" : { @@ -45425,16 +45445,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" : { @@ -45610,16 +45630,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" : { @@ -45795,16 +45815,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" : { @@ -45980,16 +46000,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" : { @@ -46165,16 +46185,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" : { @@ -46350,16 +46370,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" : { @@ -46535,16 +46555,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" : { @@ -46720,16 +46740,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" : { @@ -46875,16 +46895,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" : { @@ -47042,16 +47062,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" : { @@ -47233,16 +47253,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" : { @@ -47424,16 +47444,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" : { @@ -47615,16 +47635,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" : { @@ -47800,16 +47820,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" : { @@ -47991,16 +48011,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" : { @@ -48176,16 +48196,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" : { @@ -48361,16 +48381,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" : { @@ -48552,16 +48572,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" : { @@ -48737,16 +48757,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" : { @@ -48922,16 +48942,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" : { @@ -49113,16 +49133,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" : { @@ -49298,16 +49318,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" : { @@ -49489,16 +49509,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" : { @@ -49674,16 +49694,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" : { @@ -49859,13 +49879,13 @@ "value" : "미스키스트 I" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Misskist I" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Misskist I" @@ -50044,16 +50064,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" : { @@ -50229,13 +50249,13 @@ "value" : "미스키스트 II" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Misskist II" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Misskist II" @@ -50414,16 +50434,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" : { @@ -50599,16 +50619,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" : { @@ -50784,13 +50804,13 @@ "value" : "미스키스트 III" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Misskist III" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Misskist III" @@ -50969,16 +50989,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" : { @@ -51154,16 +51174,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" : { @@ -51339,16 +51359,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" : { @@ -51524,16 +51544,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" : { @@ -51709,16 +51729,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" : { @@ -51894,16 +51914,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" : { @@ -52079,16 +52099,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" : { @@ -52264,16 +52284,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" : { @@ -52449,16 +52469,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" : { @@ -52634,16 +52654,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" : { @@ -52819,16 +52839,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" : { @@ -53004,16 +53024,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" : { @@ -53189,16 +53209,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" : { @@ -53374,16 +53394,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" : { @@ -53559,16 +53579,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" : { @@ -53744,16 +53764,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" : { @@ -53929,16 +53949,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" : { @@ -54114,16 +54134,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" : { @@ -54305,16 +54325,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" : { @@ -54490,16 +54510,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" : { @@ -54675,16 +54695,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" : { @@ -54860,16 +54880,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" : { @@ -55051,16 +55071,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" : { @@ -55236,16 +55256,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" : { @@ -55421,16 +55441,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" : { @@ -55606,16 +55626,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" : { @@ -55791,16 +55811,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" : { @@ -55976,16 +55996,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" : { @@ -56161,16 +56181,16 @@ "value" : "신경 쓰지 마세요" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Onthoud" + "value" : "Glem det" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Glem det" + "value" : "Onthoud" } }, "pl" : { @@ -56346,16 +56366,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" : { @@ -56531,13 +56551,13 @@ "value" : "별을 바라보는 사람" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Stargazer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Stargazer" @@ -56716,16 +56736,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" : { @@ -56901,16 +56921,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" : { @@ -57086,16 +57106,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" : { @@ -57277,16 +57297,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" : { @@ -57462,16 +57482,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" : { @@ -57653,16 +57673,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" : { @@ -57844,16 +57864,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" : { @@ -58035,16 +58055,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" : { @@ -58220,16 +58240,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" : { @@ -58411,16 +58431,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" : { @@ -58602,16 +58622,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" : { @@ -58793,16 +58813,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" : { @@ -58978,16 +58998,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" : { @@ -59169,16 +59189,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" : { @@ -59354,16 +59374,16 @@ "value" : "슈퍼노트" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Supernotitie" + "value" : "Supernota" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Supernota" + "value" : "Supernotitie" } }, "pl" : { @@ -59545,16 +59565,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" : { @@ -59736,16 +59756,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" : { @@ -59927,16 +59947,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" : { @@ -60118,16 +60138,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" : { @@ -60309,16 +60329,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" : { @@ -60500,16 +60520,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" : { @@ -60691,16 +60711,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" : { @@ -60882,16 +60902,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" : { @@ -61073,16 +61093,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" : { @@ -61264,16 +61284,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" : { @@ -61455,16 +61475,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" : { @@ -61646,16 +61666,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" : { @@ -61837,16 +61857,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" : { @@ -62028,16 +62048,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" : { @@ -62219,16 +62239,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" : { @@ -62410,16 +62430,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" : { @@ -62601,16 +62621,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" : { @@ -62786,16 +62806,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" : { @@ -62977,16 +62997,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" : { @@ -63162,16 +63182,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" : { @@ -63347,16 +63367,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" : { @@ -63532,16 +63552,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" : { @@ -63723,16 +63743,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" : { @@ -63908,16 +63928,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" : { @@ -64093,16 +64113,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" : { @@ -64278,16 +64298,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" : { @@ -64463,16 +64483,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" : { @@ -64648,16 +64668,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" : { @@ -64833,16 +64853,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" : { @@ -65018,16 +65038,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" : { @@ -65203,13 +65223,13 @@ "value" : "클릭 클릭 클릭 쨍" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Click Click Click Claaang" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Click Click Click Claaang" @@ -65388,16 +65408,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" : { @@ -65573,16 +65593,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" : { @@ -65758,16 +65778,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" : { @@ -65943,16 +65963,16 @@ "value" : "야행성" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Nachtelijk" + "value" : "Nattlig" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nattlig" + "value" : "Nachtelijk" } }, "pl" : { @@ -66128,16 +66148,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" : { @@ -66313,16 +66333,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" : { @@ -66498,16 +66518,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" : { @@ -66683,16 +66703,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" : { @@ -66868,16 +66888,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" : { @@ -67053,16 +67073,16 @@ "value" : "자기 참조" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Zelfreferentie" + "value" : "Selv-referanse" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Selv-referanse" + "value" : "Zelfreferentie" } }, "pl" : { @@ -67244,16 +67264,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" : { @@ -67435,16 +67455,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" : { @@ -67620,16 +67640,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" : { @@ -67805,16 +67825,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" : { @@ -67990,16 +68010,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" : { @@ -68175,16 +68195,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" : { @@ -68360,16 +68380,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" : { @@ -68545,16 +68565,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" : { @@ -68730,16 +68750,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" : { @@ -68915,16 +68935,16 @@ "value" : "분석가" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Analist" + "value" : "Analytiker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Analytiker" + "value" : "Analist" } }, "pl" : { @@ -69001,6 +69021,86 @@ } } }, + "misskey_channel_favorite" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favourite" + } + } + } + }, + "misskey_channel_follow" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Follow" + } + } + } + }, + "misskey_channel_tab_favorites" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorites" + } + } + } + }, + "misskey_channel_tab_featured" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Featured" + } + } + } + }, + "misskey_channel_tab_following" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Following" + } + } + } + }, + "misskey_channel_tab_owned" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Owned" + } + } + } + }, + "misskey_channel_unfavorite" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unfavourite" + } + } + } + }, + "misskey_channel_unfollow" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unfollow" + } + } + } + }, "misskey_notification_achievement_earned" : { "localizations" : { "ar" : { @@ -69069,16 +69169,16 @@ "value" : "アチーブメントを獲得しました" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Je hebt een prestatie verdiend" + "value" : "Du har tjent en prestasjon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Du har tjent en prestasjon" + "value" : "Je hebt een prestatie verdiend" } }, "pl" : { @@ -69235,13 +69335,13 @@ "value" : "앱" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "App" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "App" @@ -69425,16 +69525,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" : { @@ -69609,16 +69709,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" : { @@ -69799,16 +69899,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" : { @@ -69983,16 +70083,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" : { @@ -70173,16 +70273,16 @@ "value" : "인용했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "geciteerd" + "value" : "sitert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "sitert" + "value" : "geciteerd" } }, "pl" : { @@ -70363,16 +70463,16 @@ "value" : "다시 게시했습니다" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "gepost" + "value" : "repostet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "repostet" + "value" : "gepost" } }, "pl" : { @@ -70547,16 +70647,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" : { @@ -70701,13 +70801,13 @@ "value" : "renote" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "renote" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "renote" @@ -70861,16 +70961,16 @@ "value" : "답장" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beantwoorden" + "value" : "svar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "svar" + "value" : "Beantwoorden" } }, "pl" : { @@ -71015,16 +71115,16 @@ "value" : "Unknwn" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ontbrekend" + "value" : "Uknuste" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Uknuste" + "value" : "Ontbrekend" } }, "pl" : { @@ -71175,16 +71275,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" : { @@ -71329,16 +71429,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" : { @@ -71495,16 +71595,16 @@ "value" : "신고하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporteren" + "value" : "Rapporter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporter" + "value" : "Rapporteren" } }, "pl" : { @@ -71685,16 +71785,16 @@ "value" : "혼합" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gemengd" + "value" : "Blandet" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Blandet" + "value" : "Gemengd" } }, "pl" : { @@ -71875,16 +71975,16 @@ "value" : "더보기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Meer" + "value" : "Mer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Mer" + "value" : "Meer" } }, "pl" : { @@ -72065,16 +72165,16 @@ "value" : "더보기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Meer" + "value" : "Mer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Mer" + "value" : "Meer" } }, "pl" : { @@ -72255,16 +72355,16 @@ "value" : "음소거" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Dempen" + "value" : "Demp" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Demp" + "value" : "Dempen" } }, "pl" : { @@ -72409,16 +72509,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" : { @@ -72539,16 +72639,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" : { @@ -72787,16 +72887,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" : { @@ -72917,16 +73017,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" : { @@ -73053,16 +73153,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" : { @@ -73219,16 +73319,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" : { @@ -73379,16 +73479,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" : { @@ -73515,16 +73615,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" : { @@ -73681,16 +73781,16 @@ "value" : "모두" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Allemaal" + "value" : "Alle" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Alle" + "value" : "Allemaal" } }, "pl" : { @@ -73871,16 +73971,16 @@ "value" : "댓글" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opmerking" + "value" : "Kommentar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kommentar" + "value" : "Opmerking" } }, "pl" : { @@ -74061,16 +74161,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" : { @@ -74215,16 +74315,16 @@ "value" : "メンション" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Vermelding" + "value" : "Nevn" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nevn" + "value" : "Vermelding" } }, "pl" : { @@ -74345,16 +74445,16 @@ "value" : "Notification" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Notificatie" + "value" : "Varsling" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Varsling" + "value" : "Notificatie" } }, "pl" : { @@ -74517,13 +74617,13 @@ "value" : "확인" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Ok" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Ok" @@ -74671,16 +74771,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" : { @@ -74801,16 +74901,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" : { @@ -74931,16 +75031,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" : { @@ -74993,6 +75093,26 @@ } } }, + "permission_denied_message" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You need re-login in order to access this page" + } + } + } + }, + "permission_denied_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permission Denied" + } + } + } + }, "poll_expired" : { "localizations" : { "af" : { @@ -75091,16 +75211,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" : { @@ -75245,16 +75365,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" : { @@ -75411,16 +75531,16 @@ "value" : "투표" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Stemming" + "value" : "Stem" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Stem" + "value" : "Stemming" } }, "pl" : { @@ -75601,16 +75721,16 @@ "value" : "게시물" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Berichten" + "value" : "Innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg" + "value" : "Berichten" } }, "pl" : { @@ -75791,16 +75911,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" : { @@ -75981,16 +76101,16 @@ "value" : "미디어" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Medium" + "value" : "Medier" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Medier" + "value" : "Medium" } }, "pl" : { @@ -76171,16 +76291,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" : { @@ -76361,16 +76481,16 @@ "value" : "게시물" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Berichten" + "value" : "Innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg" + "value" : "Berichten" } }, "pl" : { @@ -76551,16 +76671,16 @@ "value" : "인용하기" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Offerte" + "value" : "Sitat" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sitat" + "value" : "Offerte" } }, "pl" : { @@ -76741,16 +76861,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" : { @@ -76931,16 +77051,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" : { @@ -77115,16 +77235,16 @@ "value" : "거부" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Afwijzen" + "value" : "Avvis" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Avvis" + "value" : "Afwijzen" } }, "pl" : { @@ -77305,16 +77425,16 @@ "value" : "차단됨" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Geblokkeerd" + "value" : "Blokkert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Blokkert" + "value" : "Geblokkeerd" } }, "pl" : { @@ -77495,16 +77615,16 @@ "value" : "팔로우" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgen" + "value" : "Følg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følg" + "value" : "Volgen" } }, "pl" : { @@ -77685,16 +77805,16 @@ "value" : "팔로잉" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgt" + "value" : "Følger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følger" + "value" : "Volgt" } }, "pl" : { @@ -77875,16 +77995,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" : { @@ -78065,16 +78185,16 @@ "value" : "요청됨" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Aangevraagd" + "value" : "Forespurt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Forespurt" + "value" : "Aangevraagd" } }, "pl" : { @@ -78255,16 +78375,16 @@ "value" : "답장" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Beantwoorden" + "value" : "Svar" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Svar" + "value" : "Beantwoorden" } }, "pl" : { @@ -78439,16 +78559,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" : { @@ -78629,16 +78749,16 @@ "value" : "보고서" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporteren" + "value" : "Rapporter" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rapporter" + "value" : "Rapporteren" } }, "pl" : { @@ -78819,16 +78939,16 @@ "value" : "리트윗" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Retweet" + "value" : "Gjenkjenn" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Gjenkjenn" + "value" : "Retweet" } }, "pl" : { @@ -79003,16 +79123,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" : { @@ -79193,16 +79313,16 @@ "value" : "추가" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Toevoegen" + "value" : "Legg til" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Legg til" + "value" : "Toevoegen" } }, "pl" : { @@ -79347,16 +79467,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" : { @@ -79489,16 +79609,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" : { @@ -79631,16 +79751,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" : { @@ -79773,16 +79893,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" : { @@ -79915,16 +80035,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" : { @@ -80045,16 +80165,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" : { @@ -80211,13 +80331,13 @@ "value" : "앱" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "App" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "App" @@ -80365,16 +80485,16 @@ "value" : "ブラウザー" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "browser" + "value" : "Nettleser" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nettleser" + "value" : "browser" } }, "pl" : { @@ -80513,16 +80633,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" : { @@ -80655,13 +80775,13 @@ "value" : "RSS" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "RSS" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "RSS" @@ -80785,13 +80905,13 @@ "value" : "RSS URL" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "RSS URL" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "RSS URL" @@ -80921,16 +81041,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" : { @@ -81051,16 +81171,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" : { @@ -81181,16 +81301,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" : { @@ -81311,16 +81431,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" : { @@ -81477,16 +81597,16 @@ "value" : "검색" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Zoeken" + "value" : "Søk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Søk" + "value" : "Zoeken" } }, "pl" : { @@ -81667,16 +81787,16 @@ "value" : "게시물" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Statuses" + "value" : "Innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg" + "value" : "Statuses" } }, "pl" : { @@ -81857,16 +81977,16 @@ "value" : "검색" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Zoeken" + "value" : "Søk" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Søk" + "value" : "Zoeken" } }, "pl" : { @@ -82047,16 +82167,16 @@ "value" : "사용자" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Gebruikers" + "value" : "Brukere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Brukere" + "value" : "Gebruikers" } }, "pl" : { @@ -82201,16 +82321,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" : { @@ -82367,16 +82487,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" : { @@ -82528,16 +82648,16 @@ "value" : "表示" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Weergeven" + "value" : "Vis" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vis" + "value" : "Weergeven" } }, "pl" : { @@ -82658,16 +82778,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" : { @@ -82788,16 +82908,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" : { @@ -82918,16 +83038,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" : { @@ -83048,16 +83168,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" : { @@ -83178,16 +83298,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" : { @@ -83308,16 +83428,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" : { @@ -83474,16 +83594,16 @@ "value" : "설정" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Instellingen" + "value" : "Innstillinger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innstillinger" + "value" : "Instellingen" } }, "pl" : { @@ -83664,16 +83784,16 @@ "value" : "공유" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Delen" + "value" : "Del" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Del" + "value" : "Delen" } }, "pl" : { @@ -83818,16 +83938,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" : { @@ -83948,16 +84068,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" : { @@ -84078,16 +84198,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" : { @@ -84214,16 +84334,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" : { @@ -84381,16 +84501,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" : { @@ -84553,16 +84673,16 @@ "value" : "소셜" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Sociaal" + "value" : "Sosial" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sosial" + "value" : "Sociaal" } }, "pl" : { @@ -84695,16 +84815,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" : { @@ -84861,16 +84981,16 @@ "value" : "공유" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Delen" + "value" : "Del" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Del" + "value" : "Delen" } }, "pl" : { @@ -85021,16 +85141,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" : { @@ -85157,16 +85277,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" : { @@ -85323,16 +85443,16 @@ "value" : "팔로워" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgers" + "value" : "Følgere" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følgere" + "value" : "Volgers" } }, "pl" : { @@ -85477,16 +85597,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" : { @@ -85643,16 +85763,16 @@ "value" : "홈" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Startpagina" + "value" : "Hjem" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hjem" + "value" : "Startpagina" } }, "pl" : { @@ -85797,16 +85917,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" : { @@ -85963,16 +86083,16 @@ "value" : "공개" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Openbaar" + "value" : "Offentlig" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Offentlig" + "value" : "Openbaar" } }, "pl" : { @@ -86117,16 +86237,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" : { @@ -86283,16 +86403,16 @@ "value" : "명시된 사람만" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opgegeven" + "value" : "Spesifisert" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Spesifisert" + "value" : "Opgegeven" } }, "pl" : { @@ -86437,16 +86557,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" : { @@ -86567,16 +86687,16 @@ "value" : "確認する" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bevestigen" + "value" : "Slett databasemellomlager?" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett databasemellomlager?" + "value" : "Bevestigen" } }, "pl" : { @@ -86697,16 +86817,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" : { @@ -86863,16 +86983,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" : { @@ -87017,16 +87137,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" : { @@ -87183,16 +87303,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" : { @@ -87373,16 +87493,16 @@ "value" : "저장소" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opslagruimte" + "value" : "Lagring" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Lagring" + "value" : "Opslagruimte" } }, "pl" : { @@ -87557,16 +87677,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" : { @@ -87711,16 +87831,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" : { @@ -87871,16 +87991,16 @@ "value" : "시스템" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Systeem" + "value" : "Systemadministrasjon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Systemadministrasjon" + "value" : "Systeem" } }, "pl" : { @@ -88025,16 +88145,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" : { @@ -88155,16 +88275,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" : { @@ -88285,16 +88405,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" : { @@ -88415,16 +88535,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" : { @@ -88581,16 +88701,16 @@ "value" : "삭제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijderen" + "value" : "Slett" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Slett" + "value" : "Verwijderen" } }, "pl" : { @@ -88771,16 +88891,16 @@ "value" : "편집" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Bewerken" + "value" : "Rediger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rediger" + "value" : "Bewerken" } }, "pl" : { @@ -88925,16 +89045,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" : { @@ -89055,13 +89175,13 @@ "value" : "Tab icon" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", "value" : "Tab icon" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", "value" : "Tab icon" @@ -89191,16 +89311,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" : { @@ -89327,16 +89447,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" : { @@ -89457,16 +89577,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" : { @@ -89623,16 +89743,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" : { @@ -89777,16 +89897,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" : { @@ -89907,16 +90027,16 @@ "value" : "グループ" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Groeperen" + "value" : "Gruppe" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Gruppe" + "value" : "Groeperen" } }, "pl" : { @@ -90037,16 +90157,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" : { @@ -90167,16 +90287,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" : { @@ -90303,16 +90423,16 @@ "value" : "メインタブ" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Belangrijkste tabbladen" + "value" : "Hovedfaner" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hovedfaner" + "value" : "Belangrijkste tabbladen" } }, "pl" : { @@ -90469,16 +90589,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" : { @@ -90554,6 +90674,9 @@ } } } + }, + "Tabs" : { + }, "Theme" : { "localizations" : { @@ -90659,16 +90782,16 @@ "value" : "테마" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Thema" + "value" : "Tema" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tema" + "value" : "Thema" } }, "pl" : { @@ -90849,16 +90972,16 @@ "value" : "차단 해제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Deblokkeer" + "value" : "Avblokker" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Avblokker" + "value" : "Deblokkeer" } }, "pl" : { @@ -91039,16 +91162,16 @@ "value" : "좋아요 취소" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Anders dan" + "value" : "Ulikt" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ulikt" + "value" : "Anders dan" } }, "pl" : { @@ -91223,16 +91346,16 @@ "value" : "음소거 해제" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Deblokkeer" + "value" : "Udemp" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Udemp" + "value" : "Deblokkeer" } }, "pl" : { @@ -91413,16 +91536,16 @@ "value" : "팔로워" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volger" + "value" : "Følger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følger" + "value" : "Volger" } }, "pl" : { @@ -91603,16 +91726,16 @@ "value" : "팔로잉" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Volgt" + "value" : "Følger" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Følger" + "value" : "Volgt" } }, "pl" : { @@ -91793,16 +91916,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" : { @@ -91983,16 +92106,16 @@ "value" : "댓글" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Opmerkingen" + "value" : "Kommentarer" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kommentarer" + "value" : "Opmerkingen" } }, "pl" : { @@ -92137,16 +92260,16 @@ "value" : "リポスト" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Herposten" + "value" : "Tilbakestilling" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tilbakestilling" + "value" : "Herposten" } }, "pl" : { @@ -92303,16 +92426,16 @@ "value" : "상태" } }, - "nl" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "status" + "value" : "Innlegg" } }, - "no" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Innlegg" + "value" : "status" } }, "pl" : { @@ -92493,16 +92616,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" : { @@ -92677,16 +92800,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/DateTimeText.swift b/iosApp/flare/UI/Component/DateTimeText.swift index d9209f5ae..c3f5720a0 100644 --- a/iosApp/flare/UI/Component/DateTimeText.swift +++ b/iosApp/flare/UI/Component/DateTimeText.swift @@ -2,6 +2,7 @@ import SwiftUI import KotlinSharedUI struct DateTimeText: View { + @Environment(\.appearanceSettings.absoluteTimestamp) private var absoluteTimestamp let data: UiDateTime let fullTime: Bool @@ -10,6 +11,8 @@ struct DateTimeText: View { Text(data.platformValue, style: .date) + Text(data.platformValue, style: .time) } else if data.shouldShowFull { Text(data.platformValue, style: .date) + } else if absoluteTimestamp { + Text(data.absolute) } else { Text(data.platformValue, style: .relative) } diff --git a/iosApp/flare/UI/Component/ListErrorView.swift b/iosApp/flare/UI/Component/ListErrorView.swift index d36becebe..f61312e7b 100644 --- a/iosApp/flare/UI/Component/ListErrorView.swift +++ b/iosApp/flare/UI/Component/ListErrorView.swift @@ -23,7 +23,23 @@ struct ListErrorView: View { } .backport .glassProminentButtonStyle() - } else { + } else if let requireReLogin = error as? RequireReLoginException { + Image(systemName: "person.badge.shield.exclamationmark") + .resizable() + .scaledToFit() + .frame(width: 64, height: 64) + Text("permission_denied_title") + .multilineTextAlignment(.center) + .font(.headline) + Text("permission_denied_message") + Button { + openURL(URL(string: DeeplinkRoute.Login.shared.toUri())!) + } label: { + Text("error_login_expired_action") + } + .backport + .glassProminentButtonStyle() + } else { Image(systemName: "exclamationmark.triangle.text.page") .resizable() .scaledToFit() diff --git a/iosApp/flare/UI/Component/PagingView.swift b/iosApp/flare/UI/Component/PagingView.swift index 46f499651..49b4cff8b 100644 --- a/iosApp/flare/UI/Component/PagingView.swift +++ b/iosApp/flare/UI/Component/PagingView.swift @@ -102,12 +102,40 @@ extension PagingView { } ) } + + + init( + data: PagingState, + @ViewBuilder + successContent: @escaping (T) -> SuccessContent, + @ViewBuilder + loadingContent: @escaping () -> LoadingContent, + @ViewBuilder + errorContent: @escaping (KotlinThrowable, @escaping () -> Void) -> ErrorContent, + @ViewBuilder + emptyContent: @escaping () -> EmptyContent + ) { + self.init( + data: data, + emptyContent: { emptyContent() }, + errorContent: { error, retry in + errorContent(error, retry) + }, + loadingContent: { index, loadingCount in + loadingContent() + }, + successContent: { item, index, itemCount in + successContent(item) + } + ) + } + } struct UserPagingView: View { @Environment(\.openURL) private var openURL - let data: PagingState + let data: PagingState var body: some View { PagingView(data: data) { user in UserCompatView(data: user) diff --git a/iosApp/flare/UI/Component/Status/StatusView.swift b/iosApp/flare/UI/Component/Status/StatusView.swift index 40d1cd832..af8a9a69d 100644 --- a/iosApp/flare/UI/Component/Status/StatusView.swift +++ b/iosApp/flare/UI/Component/Status/StatusView.swift @@ -187,7 +187,15 @@ struct StatusView: View { ) } - if case .reaction(let reaction) = onEnum(of: data.bottomContent), showMedia { + if case .reaction(let reaction) = onEnum(of: data.bottomContent), showMedia, !isQuote { + if let channel = reaction.channel { + HStack { + Image(.faTv) + Text(channel.name) + } + .font(.footnote) + .foregroundStyle(.secondary) + } if !reaction.emojiReactions.isEmpty { StatusReactionView(data: reaction) } diff --git a/iosApp/flare/UI/Component/Status/StatusVisibilityView.swift b/iosApp/flare/UI/Component/Status/StatusVisibilityView.swift index 0b82b169a..8d8653ccb 100644 --- a/iosApp/flare/UI/Component/Status/StatusVisibilityView.swift +++ b/iosApp/flare/UI/Component/Status/StatusVisibilityView.swift @@ -9,6 +9,7 @@ struct StatusVisibilityView: View { case .home: Image("fa-lock-open") case .followers: Image("fa-lock") case .specified: Image("fa-at") + case .channel: Image(.faTv) } } } diff --git a/iosApp/flare/UI/Component/TabIcon.swift b/iosApp/flare/UI/Component/TabIcon.swift index 5817c5ab1..9cc6aa331 100644 --- a/iosApp/flare/UI/Component/TabIcon.swift +++ b/iosApp/flare/UI/Component/TabIcon.swift @@ -33,6 +33,7 @@ extension TitleType { case .liked: String(localized: "liked_tab_title") case .allRssFeeds: String(localized: "all_rss_feeds_title") case .posts: String(localized: "posts_title") + case .channel: String(localized: "channel_title") } return text case .text(let text): @@ -137,6 +138,7 @@ extension IconType.MaterialMaterialIcon { case .messages: "fa-message" case .rss: "fa-square-rss" case .weibo: "fa-weibo" + case .channel: "fa-tv" } } } diff --git a/iosApp/flare/UI/Component/UiListView.swift b/iosApp/flare/UI/Component/UiListView.swift index fc5ff5c47..f7fbd9244 100644 --- a/iosApp/flare/UI/Component/UiListView.swift +++ b/iosApp/flare/UI/Component/UiListView.swift @@ -3,6 +3,52 @@ import KotlinSharedUI struct UiListView: View { let data: UiList + var body: some View { + switch data { + case let list as UiList.List: + UiListRow(data: list) + case let feed as UiList.Feed: + UiFeedRow(data: feed) + case let antenna as UiList.Antenna: + UiAntennaRow(data: antenna) + case let channel as UiList.Channel: + UiChannelRow(data: channel) + default: + EmptyView() + } + } +} + +private struct UiListRow: View { + let data: UiList.List + var body: some View { + VStack( + alignment: .leading, + spacing: 8 + ) { + Label { + Text(data.title) + } icon: { + if let image = data.avatar { + NetworkImage(data: image) + .frame(width: 24, height: 24) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } else { + Image(systemName: "list.bullet") + .frame(width: 24, height: 24) + } + } + if let desc = data.description_, !desc.isEmpty { + Text(desc) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } +} + +private struct UiFeedRow: View { + let data: UiList.Feed var body: some View { VStack( alignment: .leading, @@ -12,10 +58,12 @@ struct UiListView: View { Text(data.title) } icon: { if let image = data.avatar { - AvatarView(data: image) + NetworkImage(data: image) .frame(width: 24, height: 24) + .clipShape(RoundedRectangle(cornerRadius: 4)) } else { - Image("fa-list") + Image(systemName: "rss") + .frame(width: 24, height: 24) } } if let desc = data.description_, !desc.isEmpty { @@ -27,6 +75,51 @@ struct UiListView: View { } } +private struct UiAntennaRow: View { + let data: UiList.Antenna + var body: some View { + VStack( + alignment: .leading, + spacing: 8 + ) { + Label { + Text(data.title) + } icon: { + Image(systemName: "antenna.radiowaves.left.and.right") + .frame(width: 24, height: 24) + } + } + } +} + +private struct UiChannelRow: View { + let data: UiList.Channel + var body: some View { + VStack( + alignment: .leading, + spacing: 8 + ) { + Label { + Text(data.title) + } icon: { + if let image = data.banner { + NetworkImage(data: image) + .frame(width: 24, height: 24) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } else { + Image(systemName: "tv") + .frame(width: 24, height: 24) + } + } + if let desc = data.description_, !desc.isEmpty { + RichText(text: desc) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } +} + struct UiListPlaceholder: View { var body: some View { VStack( diff --git a/iosApp/flare/UI/Component/UserCompatView.swift b/iosApp/flare/UI/Component/UserCompatView.swift index 8e684b0ce..5b6531388 100644 --- a/iosApp/flare/UI/Component/UserCompatView.swift +++ b/iosApp/flare/UI/Component/UserCompatView.swift @@ -3,7 +3,7 @@ import KotlinSharedUI struct UserCompatView: View { @Environment(\.openURL) private var openURL - let data: UiUserV2 + let data: UiProfile let trailing: () -> TrailingContent let onClicked: (() -> Void)? var body: some View { @@ -38,7 +38,7 @@ struct UserCompatView: View { } extension UserCompatView { - init(data: UiUserV2) where TrailingContent == EmptyView { + init(data: UiProfile) where TrailingContent == EmptyView { self.data = data self.trailing = { EmptyView() diff --git a/iosApp/flare/UI/Component/UserOnelineView.swift b/iosApp/flare/UI/Component/UserOnelineView.swift index ee587f659..edd015e11 100644 --- a/iosApp/flare/UI/Component/UserOnelineView.swift +++ b/iosApp/flare/UI/Component/UserOnelineView.swift @@ -3,7 +3,7 @@ import KotlinSharedUI struct UserOnelineView: View { @Environment(\.openURL) private var openURL - let data: UiUserV2 + let data: UiProfile let showAvatar: Bool let trailing: () -> TrailingContent let onClicked: (() -> Void)? @@ -39,7 +39,7 @@ struct UserOnelineView: View { } extension UserOnelineView { - init(data: UiUserV2) where TrailingContent == EmptyView { + init(data: UiProfile) where TrailingContent == EmptyView { self.data = data self.trailing = { EmptyView() diff --git a/iosApp/flare/UI/Route/TabItemView.swift b/iosApp/flare/UI/Route/TabItemView.swift index d99ffc9ab..6890f8144 100644 --- a/iosApp/flare/UI/Route/TabItemView.swift +++ b/iosApp/flare/UI/Route/TabItemView.swift @@ -39,6 +39,8 @@ extension TabItem { DMListScreen(accountType: directMessageTabItem.account) case .antennasListTabItem(let antennasListTabItem): AntennasListScreen(accountType: antennasListTabItem.account) + case .channelListTabItem(let channelListTabItem): + ChannelListScreen(accountType: channelListTabItem.account) } } } diff --git a/iosApp/flare/UI/Screen/AllListScreen.swift b/iosApp/flare/UI/Screen/AllListScreen.swift index 4fb08ca7d..988f341bf 100644 --- a/iosApp/flare/UI/Screen/AllListScreen.swift +++ b/iosApp/flare/UI/Screen/AllListScreen.swift @@ -30,7 +30,7 @@ struct AllListScreen: View { ) ) { UiListView(data: item) - .if(!item.readonly) { view in + .if(item.readonly) { view in view.swipeActions(edge: .leading) { Button { editListId = item.id diff --git a/iosApp/flare/UI/Screen/ChannelListScreen.swift b/iosApp/flare/UI/Screen/ChannelListScreen.swift new file mode 100644 index 000000000..ff9dfc9ee --- /dev/null +++ b/iosApp/flare/UI/Screen/ChannelListScreen.swift @@ -0,0 +1,110 @@ +import SwiftUI +@preconcurrency import KotlinSharedUI + +struct ChannelListScreen: View { + @StateObject private var presenter: KotlinPresenter + let accountType: AccountType + @State private var selectedTab: MisskeyChannelListPresenterStateType = .following + init(accountType: AccountType) { + self.accountType = accountType + self._presenter = .init(wrappedValue: .init(presenter: MisskeyChannelListPresenter(accountType: accountType))) + } + + var body: some View { + List { + PagingView(data: presenter.state.data) { item in + NavigationLink(value: Route.timeline( + Misskey.ChannelTimelineTabItem( + channelId: item.id, + account: accountType, + metaData: TabMetaData( + title: TitleType.Text(content: item.title), + icon: IconType.Material(icon: .list) + ) + ) + )) { + UiListView(data: item) + .contextMenu { + if case .channel(let data) = onEnum(of: item) { + if let isFollowing = data.isFollowing { + if isFollowing.boolValue { + Button { + presenter.state.unfollow(list: item) + } label: { + Label { + Text("misskey_channel_unfollow") + } icon: { + Image(.faMinus) + } + } + } else { + Button { + presenter.state.follow(list: item) + } label: { + Label { + Text("misskey_channel_follow") + } icon: { + Image(.faPlus) + } + } + } + } + if let isFavorited = data.isFavorited { + if isFavorited.boolValue { + Button { + presenter.state.unfavorite(list: item) + } label: { + Label { + Text("misskey_channel_unfavorite") + } icon: { + Image(.faHeartCircleMinus) + } + } + } else { + Button { + presenter.state.favorite(list: item) + } label: { + Label { + Text("misskey_channel_favorite") + } icon: { + Image(.faHeartCirclePlus ) + } + } + } + } + } + } + } + } loadingContent: { + UiListPlaceholder() + } + } + .safeAreaInset(edge: .top, content: { + Picker("Tabs", selection: $selectedTab) { + ForEach(presenter.state.allTypes, id: \.self) { tab in + Text(tab.localizedName).tag(tab) + } + } + .pickerStyle(.segmented) + .padding(.horizontal) + }) + .refreshable { + try? await presenter.state.refreshSuspend() + } + .onChange(of: selectedTab, { oldValue, newValue in + presenter.state.setType(data: newValue) + }) + .navigationTitle("channels_title") + } +} + +extension MisskeyChannelListPresenterStateType { + var localizedName: String { + switch self { + case .following: return NSLocalizedString("misskey_channel_tab_following", comment: "") + case .favorites: return NSLocalizedString("misskey_channel_tab_favorites", comment: "") + case .owned: return NSLocalizedString("misskey_channel_tab_owned", comment: "") + case .featured: return NSLocalizedString("misskey_channel_tab_featured", comment: "") + } + } +} diff --git a/iosApp/flare/UI/Screen/ComposeScreen.swift b/iosApp/flare/UI/Screen/ComposeScreen.swift index 518954461..21e27e4c3 100644 --- a/iosApp/flare/UI/Screen/ComposeScreen.swift +++ b/iosApp/flare/UI/Screen/ComposeScreen.swift @@ -745,6 +745,8 @@ extension UiTimeline.ItemContentStatusTopEndContentVisibilityType { return LocalizedStringResource("status_visibility_followers") case .specified: return LocalizedStringResource("status_visibility_specified") + case .channel: + return LocalizedStringResource("status_visibility_public") } } var desc: LocalizedStringResource { @@ -757,6 +759,8 @@ extension UiTimeline.ItemContentStatusTopEndContentVisibilityType { return LocalizedStringResource("status_visibility_followers_description") case .specified: return LocalizedStringResource("status_visibility_specified_description") + case .channel: + return LocalizedStringResource("status_visibility_public_description") } } } diff --git a/iosApp/flare/UI/Screen/EditListMemberScreen.swift b/iosApp/flare/UI/Screen/EditListMemberScreen.swift new file mode 100644 index 000000000..43c98050e --- /dev/null +++ b/iosApp/flare/UI/Screen/EditListMemberScreen.swift @@ -0,0 +1,60 @@ +import SwiftUI +import KotlinSharedUI + +struct EditListMemberScreen: View { + @Environment(\.dismiss) var dismiss + @StateObject private var presenter: KotlinPresenter + @State private var searchText: String = "" + + init(accountType: AccountType, listId: String) { + self._presenter = .init(wrappedValue: .init(presenter: EditListMemberPresenter(accountType: accountType, listId: listId))) + } + + var body: some View { + List { + PagingView(data: presenter.state.users) { user in + if let added = user.second, let data = user.first { + UserCompatView(data: data) { + if added.boolValue { + Button(role: .destructive) { + presenter.state.removeMember(userKey: data.key) + } label: { + Image(.faTrash) + } + } else { + Button { + presenter.state.addMember(userKey: data.key) + } label: { + Image(.faPlus) + } + } + } onClicked: { + + } + } else { + EmptyView() + } + } loadingContent: { + UserLoadingView() + } errorContent: { error, retry in + ListErrorView(error: error, onRetry: retry) + } emptyContent: { + ListEmptyView() + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + dismiss() + } label: { + Image("fa-xmark") + } + } + } + .navigationTitle("list_edit_member_title") + .searchable(text: $searchText, prompt: "search") + .onSubmit(of: .search) { + presenter.state.setFilter(value: searchText) + } + } +} diff --git a/iosApp/flare/UI/Screen/EditListScreen.swift b/iosApp/flare/UI/Screen/EditListScreen.swift index d442c9043..548cf6d55 100644 --- a/iosApp/flare/UI/Screen/EditListScreen.swift +++ b/iosApp/flare/UI/Screen/EditListScreen.swift @@ -33,7 +33,12 @@ struct EditListScreen: View { .frame(width: 96, height: 96) } else { StateView(state: presenter.state.listInfo) { info in - if let remote = info.avatar { + let avatar: String? = switch onEnum(of: info) { + case .list(let data): data.avatar + case .feed(let data): data.avatar + default: nil + } + if let remote = avatar { NetworkImage(data: remote) .frame(width: 96, height: 96) } else { @@ -113,7 +118,11 @@ struct EditListScreen: View { } .onSuccessOf(of: presenter.state.listInfo, data: { listInfo in self.title = listInfo.title - self.desc = listInfo.description_ ?? "" + switch onEnum(of: listInfo) { + case .list(let data): self.desc = data.description_ ?? "" + case .feed(let data): self.desc = data.description_ ?? "" + default: self.desc = "" + } }) .navigationTitle("list_edit_title") .sheet(isPresented: $showEditMember, content: { @@ -179,62 +188,3 @@ struct EditListScreen: View { } -struct EditListMemberScreen: View { - @Environment(\.dismiss) var dismiss - @StateObject private var presenter: KotlinPresenter - @State private var searchText: String = "" - - init(accountType: AccountType, listId: String) { - self._presenter = .init(wrappedValue: .init(presenter: EditListMemberPresenter(accountType: accountType, listId: listId))) - } - - var body: some View { - List { - PagingView(data: presenter.state.users) { user in - if let added = user.second, let data = user.first { - UserCompatView(data: data) { - if added.boolValue { - Button(role: .destructive) { - presenter.state.removeMember(userKey: data.key) - } label: { - Image(.faTrash) - } - } else { - Button { - presenter.state.addMember(userKey: data.key) - } label: { - Image(.faPlus) - } - } - } onClicked: { - - } - } else { - EmptyView() - } - } loadingContent: { - UserLoadingView() - } errorContent: { error, retry in - if error is EmptyQueryException { - ListEmptyView() - } else { - ListErrorView(error: error, onRetry: retry) - } - } - } - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button { - dismiss() - } label: { - Image("fa-xmark") - } - } - } - .navigationTitle("list_edit_member_title") - .searchable(text: $searchText, prompt: "search") - .onSubmit(of: .search) { - presenter.state.setFilter(value: searchText) - } - } -} diff --git a/iosApp/flare/UI/Screen/ProfileScreen.swift b/iosApp/flare/UI/Screen/ProfileScreen.swift index 6e65c3988..57807c30c 100644 --- a/iosApp/flare/UI/Screen/ProfileScreen.swift +++ b/iosApp/flare/UI/Screen/ProfileScreen.swift @@ -187,7 +187,7 @@ struct ProfileHeader: View { let user: UiState let relation: UiState let isMe: UiState - let onFollowClick: (UiUserV2, UiRelation) -> Void + let onFollowClick: (UiProfile, UiRelation) -> Void let onFollowingClick: (MicroBlogKey) -> Void let onFansClick: (MicroBlogKey) -> Void var body: some View { diff --git a/iosApp/flare/UI/Screen/TabSettingsScreen.swift b/iosApp/flare/UI/Screen/TabSettingsScreen.swift index 9e034cd3f..4ead7798a 100644 --- a/iosApp/flare/UI/Screen/TabSettingsScreen.swift +++ b/iosApp/flare/UI/Screen/TabSettingsScreen.swift @@ -429,6 +429,8 @@ struct AccountTabListView: View { .tag(index + 1) case .list: Text("all_lists_title") .tag(index + 1) + case .channel: Text("channels_title") + .tag(index + 1) } } } label: { diff --git a/shared/src/appleMain/kotlin/dev/dimension/flare/ui/humanizer/AppleFormatter.kt b/shared/src/appleMain/kotlin/dev/dimension/flare/ui/humanizer/AppleFormatter.kt index aa3f5178d..b0c3e43c6 100644 --- a/shared/src/appleMain/kotlin/dev/dimension/flare/ui/humanizer/AppleFormatter.kt +++ b/shared/src/appleMain/kotlin/dev/dimension/flare/ui/humanizer/AppleFormatter.kt @@ -54,15 +54,8 @@ internal class AppleFormatter( formatter.timeStyle = NSDateFormatterShortStyle } daysDiff < 7 -> { - val dayFormatter = NSDateFormatter() - dayFormatter.setLocalizedDateFormatFromTemplate("E") - val day = dayFormatter.stringFromDate(date) - - formatter.dateStyle = NSDateFormatterNoStyle + formatter.dateStyle = NSDateFormatterShortStyle formatter.timeStyle = NSDateFormatterShortStyle - val time = formatter.stringFromDate(date) - - return "$day $time" } else -> { formatter.dateStyle = NSDateFormatterShortStyle diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/common/BasePagingSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/common/BasePagingSource.kt new file mode 100644 index 000000000..8265db11f --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/common/BasePagingSource.kt @@ -0,0 +1,20 @@ +package dev.dimension.flare.common + +import androidx.paging.PagingSource +import dev.dimension.flare.data.repository.DebugRepository + +internal abstract class BasePagingSource : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult = + try { + doLoad(params) + } catch (e: Throwable) { + onError(e) + DebugRepository.error(e) + LoadResult.Error(e) + } + + abstract suspend fun doLoad(params: LoadParams): LoadResult + + protected open fun onError(e: Throwable) { + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/common/BaseRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/common/BaseRemoteMediator.kt index 4fbe1f565..4441fc4f1 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/common/BaseRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/common/BaseRemoteMediator.kt @@ -2,16 +2,9 @@ package dev.dimension.flare.common import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType -import androidx.paging.PagingSource import androidx.paging.PagingState import androidx.paging.RemoteMediator -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.DbPagingKey -import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus import dev.dimension.flare.data.repository.DebugRepository -import dev.dimension.flare.ui.model.UiTimeline @OptIn(ExperimentalPagingApi::class) internal abstract class BaseRemoteMediator : RemoteMediator() { @@ -35,124 +28,3 @@ internal abstract class BaseRemoteMediator : RemoteMedia protected open fun onError(e: Throwable) { } } - -internal sealed interface BaseTimelineLoader { - data object NotSupported : BaseTimelineLoader -} - -@OptIn(ExperimentalPagingApi::class) -internal abstract class BaseTimelineRemoteMediator( - private val database: CacheDatabase, -) : BaseRemoteMediator(), - BaseTimelineLoader { - abstract val pagingKey: String - - final override suspend fun doLoad( - loadType: LoadType, - state: PagingState, - ): MediatorResult { - val request: Request = - when (loadType) { - LoadType.REFRESH -> Request.Refresh - LoadType.PREPEND -> { - val previousKey = - database.pagingTimelineDao().getPagingKey(pagingKey)?.prevKey - ?: return MediatorResult.Success(endOfPaginationReached = true) - Request.Prepend(previousKey) - } - LoadType.APPEND -> { - val nextKey = - database.pagingTimelineDao().getPagingKey(pagingKey)?.nextKey - ?: return MediatorResult.Success(endOfPaginationReached = true) - Request.Append(nextKey) - } - } - - val result = - timeline( - pageSize = state.config.pageSize, - request = request, - ) - database.connect { - if (loadType == LoadType.REFRESH) { - result.data.groupBy { it.timeline.pagingKey }.keys.forEach { key -> - database - .pagingTimelineDao() - .delete(pagingKey = key) - } - database.pagingTimelineDao().deletePagingKey(pagingKey) - database.pagingTimelineDao().insertPagingKey( - DbPagingKey( - pagingKey = pagingKey, - nextKey = result.nextKey, - prevKey = result.previousKey, - ), - ) - } else if (loadType == LoadType.PREPEND && result.previousKey != null) { - database.pagingTimelineDao().updatePagingKeyPrevKey( - pagingKey = pagingKey, - prevKey = result.previousKey, - ) - } else if (loadType == LoadType.APPEND && result.nextKey != null) { - database.pagingTimelineDao().updatePagingKeyNextKey( - pagingKey = pagingKey, - nextKey = result.nextKey, - ) - } - saveToDatabase(database, result.data) - } - return MediatorResult.Success( - endOfPaginationReached = - result.endOfPaginationReached || - when (loadType) { - LoadType.REFRESH -> false - LoadType.PREPEND -> result.previousKey == null - LoadType.APPEND -> result.nextKey == null - }, - ) - } - - abstract suspend fun timeline( - pageSize: Int, - request: Request, - ): Result - - data class Result( - val endOfPaginationReached: Boolean, - val data: List = emptyList(), - val nextKey: String? = null, - val previousKey: String? = null, - ) - - sealed interface Request { - data object Refresh : Request - - data class Prepend( - val previousKey: String, - ) : Request - - data class Append( - val nextKey: String, - ) : Request - } -} - -internal fun interface BaseTimelinePagingSourceFactory : BaseTimelineLoader { - abstract fun create(): BasePagingSource -} - -internal abstract class BasePagingSource : PagingSource() { - override suspend fun load(params: LoadParams): LoadResult = - try { - doLoad(params) - } catch (e: Throwable) { - onError(e) - DebugRepository.error(e) - LoadResult.Error(e) - } - - abstract suspend fun doLoad(params: LoadParams): LoadResult - - protected open fun onError(e: Throwable) { - } -} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/common/SerializableImmutableList.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/common/SerializableImmutableList.kt new file mode 100644 index 000000000..31f65267e --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/common/SerializableImmutableList.kt @@ -0,0 +1,62 @@ +package dev.dimension.flare.common + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.toPersistentList +import kotlinx.collections.immutable.toPersistentMap +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SealedSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.serialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +// https://github.com/Kotlin/kotlinx.collections.immutable/issues/63 +internal typealias SerializableImmutableList = + @Serializable(ImmutableListSerializer::class) + ImmutableList + +internal class ImmutableListSerializer( + private val dataSerializer: KSerializer, +) : KSerializer> { + @OptIn(SealedSerializationApi::class) + private class PersistentListDescriptor : SerialDescriptor by serialDescriptor>() { + override val serialName: String = "kotlinx.serialization.immutable.ImmutableList" + } + + override val descriptor: SerialDescriptor = PersistentListDescriptor() + + override fun serialize( + encoder: Encoder, + value: ImmutableList, + ) = ListSerializer(dataSerializer).serialize(encoder, value.toList()) + + override fun deserialize(decoder: Decoder): ImmutableList = ListSerializer(dataSerializer).deserialize(decoder).toPersistentList() +} + +internal typealias SerializableImmutableMap = + @Serializable(ImmutableMapSerializer::class) + ImmutableMap + +internal class ImmutableMapSerializer( + private val keySerializer: KSerializer, + private val valueSerializer: KSerializer, +) : KSerializer> { + @OptIn(SealedSerializationApi::class) + private class PersistentMapDescriptor : SerialDescriptor by serialDescriptor>() { + override val serialName: String = "kotlinx.serialization.immutable.ImmutableMap" + } + + override val descriptor: SerialDescriptor = PersistentMapDescriptor() + + override fun serialize( + encoder: Encoder, + value: ImmutableMap, + ) = MapSerializer(keySerializer, valueSerializer).serialize(encoder, value.toMap()) + + override fun deserialize(decoder: Decoder): ImmutableMap = + MapSerializer(keySerializer, valueSerializer).deserialize(decoder).toPersistentMap() +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/CacaheDatabase.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/CacaheDatabase.kt index ce4a34ce9..130cee269 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/CacaheDatabase.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/CacaheDatabase.kt @@ -8,7 +8,7 @@ import androidx.room.TypeConverters import androidx.room.immediateTransaction import androidx.room.useWriterConnection -internal const val CACHE_DATABASE_VERSION = 22 +internal const val CACHE_DATABASE_VERSION = 23 @Database( entities = [ @@ -24,6 +24,9 @@ internal const val CACHE_DATABASE_VERSION = 22 dev.dimension.flare.data.database.cache.model.DbUserHistory::class, dev.dimension.flare.data.database.cache.model.DbEmojiHistory::class, dev.dimension.flare.data.database.cache.model.DbPagingKey::class, + dev.dimension.flare.data.database.cache.model.DbList::class, + dev.dimension.flare.data.database.cache.model.DbListPaging::class, + dev.dimension.flare.data.database.cache.model.DbListMember::class, ], version = CACHE_DATABASE_VERSION, exportSchema = false, @@ -36,6 +39,7 @@ internal const val CACHE_DATABASE_VERSION = 22 dev.dimension.flare.data.database.cache.model.StatusConverter::class, dev.dimension.flare.data.database.cache.model.UserContentConverters::class, dev.dimension.flare.data.database.cache.model.MessageContentConverters::class, + dev.dimension.flare.data.database.cache.model.ListContentConverters::class, ) @ConstructedBy(CacheDatabaseConstructor::class) internal abstract class CacheDatabase : RoomDatabase() { @@ -50,6 +54,8 @@ internal abstract class CacheDatabase : RoomDatabase() { abstract fun pagingTimelineDao(): dev.dimension.flare.data.database.cache.dao.PagingTimelineDao abstract fun messageDao(): dev.dimension.flare.data.database.cache.dao.MessageDao + + abstract fun listDao(): dev.dimension.flare.data.database.cache.dao.ListDao } // The Room compiler generates the `actual` implementations. diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/ListDao.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/ListDao.kt new file mode 100644 index 000000000..d4956dc98 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/dao/ListDao.kt @@ -0,0 +1,101 @@ +package dev.dimension.flare.data.database.cache.dao + +import androidx.paging.PagingSource +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.DbList +import dev.dimension.flare.data.database.cache.model.DbListMember +import dev.dimension.flare.data.database.cache.model.DbListMemberWithContent +import dev.dimension.flare.data.database.cache.model.DbListPaging +import dev.dimension.flare.data.database.cache.model.DbListWithContent +import dev.dimension.flare.data.database.cache.model.DbUserWithListMembership +import dev.dimension.flare.model.DbAccountType +import dev.dimension.flare.model.MicroBlogKey +import kotlinx.coroutines.flow.Flow + +@Dao +internal interface ListDao { + @Transaction + @Query( + "SELECT * FROM DbListPaging WHERE pagingKey = :pagingKey", + ) + fun getPagingSource(pagingKey: String): PagingSource + + @Transaction + @Query("SELECT * FROM DbListPaging WHERE pagingKey = :pagingKey") + fun getListKeysFlow(pagingKey: String): Flow> + + @Query("SELECT * FROM DbList WHERE listKey = :listKey AND accountType = :accountType") + fun getList( + listKey: MicroBlogKey, + accountType: DbAccountType, + ): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllPaging(timelines: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllList(lists: List) + + @Query("DELETE FROM DbListPaging WHERE pagingKey = :pagingKey") + suspend fun deleteByPagingKey(pagingKey: String) + + @Query("DELETE FROM DbList WHERE accountType = :accountType") + suspend fun deleteByAccountType(accountType: String) + + @Query("DELETE FROM DbList WHERE listKey = :listKey AND accountType = :accountType") + suspend fun deleteByListKey( + listKey: MicroBlogKey, + accountType: DbAccountType, + ) + + @Query("DELETE FROM DbListPaging WHERE listKey = :listKey AND accountType = :accountType") + suspend fun deletePagingByListKey( + listKey: MicroBlogKey, + accountType: DbAccountType, + ) + + @Query("DELETE FROM DbList") + suspend fun clearAllLists() + + @Query("DELETE FROM DbListPaging") + suspend fun clearAllListPaging() + + @Query("UPDATE DbList SET content = :content WHERE listKey = :listKey AND accountType = :accountType") + suspend fun updateListContent( + listKey: MicroBlogKey, + accountType: DbAccountType, + content: DbList.ListContent, + ) + + @Transaction + @Query("SELECT * FROM DbListMember WHERE listKey = :listKey") + fun getListMembers(listKey: MicroBlogKey): PagingSource + + @Transaction + @Query("SELECT * FROM DbListMember WHERE listKey = :listKey") + fun getListMembersFlow(listKey: MicroBlogKey): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllMember(members: List) + + @Query("DELETE FROM DbListMember WHERE listKey = :listKey") + suspend fun deleteMembersByListKey(listKey: MicroBlogKey) + + @Query("DELETE FROM DbListMember WHERE memberKey = :memberKey AND listKey = :listKey") + suspend fun deleteMemberFromList( + memberKey: MicroBlogKey, + listKey: MicroBlogKey, + ) + + @Transaction + @Query("SELECT * FROM DbUser WHERE userKey = :userKey") + fun getUserByKey(userKey: MicroBlogKey): PagingSource + + @Transaction + @Query("SELECT * FROM DbUser WHERE userKey = :userKey") + fun getUserByKeyFlow(userKey: MicroBlogKey): Flow +} 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 220faf50d..468cda920 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 @@ -688,7 +688,7 @@ private fun PostView.toDbStatusWithUser(accountKey: MicroBlogKey): DbStatusWithU ) } -private fun ProfileView.toDbUser(host: String) = +internal fun ProfileView.toDbUser(host: String) = DbUser( userKey = MicroBlogKey( @@ -710,7 +710,7 @@ private fun ProfileView.toDbUser(host: String) = ), ) -private fun ProfileViewBasic.toDbUser(host: String) = +internal fun ProfileViewBasic.toDbUser(host: String) = DbUser( userKey = MicroBlogKey( @@ -724,7 +724,7 @@ private fun ProfileViewBasic.toDbUser(host: String) = content = UserContent.BlueskyLite(this), ) -private fun chat.bsky.actor.ProfileViewBasic.toDbUser(host: String) = +internal fun chat.bsky.actor.ProfileViewBasic.toDbUser(host: String) = DbUser( userKey = MicroBlogKey( 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 index 6da40888f..e06011326 100644 --- 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 @@ -135,7 +135,7 @@ private fun Note.toDbStatusWithUser( ) } -private fun UserLite.toDbUser(accountHost: String) = +internal fun UserLite.toDbUser(accountHost: String) = DbUser( userKey = 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 new file mode 100644 index 000000000..88938acce --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/DbList.kt @@ -0,0 +1,115 @@ +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 +import dev.dimension.flare.common.decodeJson +import dev.dimension.flare.common.encodeJson +import dev.dimension.flare.data.database.cache.model.DbList.ListContent +import dev.dimension.flare.model.DbAccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiList +import kotlinx.serialization.Serializable +import kotlin.uuid.Uuid + +@Entity( + indices = [Index(value = ["listKey", "accountType"], unique = true)], +) +internal data class DbList( + val listKey: MicroBlogKey, + val accountType: DbAccountType, + val content: ListContent, + @PrimaryKey + val id: String = "${accountType}_$listKey", +) { + @Serializable + data class ListContent( + val data: UiList, + ) +} + +internal class ListContentConverters { + @TypeConverter + fun fromMessageContent(content: ListContent): String = content.encodeJson() + + @TypeConverter + fun toMessageContent(value: String): ListContent = value.decodeJson() +} + +@Entity( + indices = [ + Index( + value = ["accountType", "listKey", "pagingKey"], + unique = true, + ), + ], +) +internal data class DbListPaging( + val accountType: DbAccountType, + val pagingKey: String, + val listKey: MicroBlogKey, + @PrimaryKey + val _id: String = Uuid.random().toString(), +) + +internal data class DbListWithContent( + @Embedded + val paging: DbListPaging, + @Relation( + parentColumn = "listKey", + entityColumn = "listKey", + entity = DbList::class, + ) + val list: DbList, +) + +@Entity( + indices = [ + Index( + value = ["listKey", "memberKey"], + unique = true, + ), + ], +) +internal data class DbListMember( + val listKey: MicroBlogKey, + val memberKey: MicroBlogKey, + @PrimaryKey + val id: String = "${listKey}_$memberKey", +) + +internal data class DbListMemberWithContent( + @Embedded + val member: DbListMember, + @Relation( + parentColumn = "memberKey", + entityColumn = "userKey", + entity = DbUser::class, + ) + val user: DbUser, +) + +internal data class DbListMemberWithList( + @Embedded + val member: DbListMember, + @Relation( + parentColumn = "listKey", + entityColumn = "listKey", + entity = DbList::class, + ) + val list: DbList, +) + +internal data class DbUserWithListMembership( + @Embedded + val user: DbUser, + @Relation( + parentColumn = "userKey", + entityColumn = "memberKey", + entity = DbListMember::class, + ) + val listMemberships: List, +) 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 381e93468..ce00d3ab2 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 @@ -1,32 +1,22 @@ package dev.dimension.flare.data.datasource.bluesky import androidx.paging.ExperimentalPagingApi -import androidx.paging.LoadType import androidx.paging.Pager import androidx.paging.PagingData import androidx.paging.PagingState import androidx.paging.cachedIn import androidx.paging.map import app.bsky.actor.GetProfileQueryParams -import app.bsky.actor.PreferencesUnion -import app.bsky.actor.PutPreferencesRequest -import app.bsky.actor.SavedFeed -import app.bsky.actor.SavedFeedType import app.bsky.bookmark.CreateBookmarkRequest import app.bsky.bookmark.DeleteBookmarkRequest import app.bsky.embed.Images import app.bsky.embed.ImagesImage import app.bsky.embed.Record -import app.bsky.feed.GetFeedGeneratorQueryParams -import app.bsky.feed.GetFeedGeneratorsQueryParams 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.GetListQueryParams -import app.bsky.graph.GetListsQueryParams -import app.bsky.graph.Listitem import app.bsky.graph.MuteActorRequest import app.bsky.graph.UnmuteActorRequest import app.bsky.notification.ListNotificationsQueryParams @@ -49,22 +39,13 @@ import com.atproto.identity.ResolveHandleQueryParams import com.atproto.moderation.CreateReportRequest import com.atproto.moderation.CreateReportRequestSubjectUnion import com.atproto.moderation.Token -import com.atproto.repo.ApplyWritesDelete -import com.atproto.repo.ApplyWritesRequest -import com.atproto.repo.ApplyWritesRequestWriteUnion import com.atproto.repo.CreateRecordRequest import com.atproto.repo.CreateRecordResponse import com.atproto.repo.DeleteRecordRequest -import com.atproto.repo.ListRecordsQueryParams -import com.atproto.repo.ListRecordsRecord -import com.atproto.repo.PutRecordRequest import com.atproto.repo.StrongRef import dev.dimension.flare.common.BasePagingSource -import dev.dimension.flare.common.BaseRemoteMediator -import dev.dimension.flare.common.BaseTimelineLoader 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 @@ -83,17 +64,18 @@ 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.DirectMessageDataSource -import dev.dimension.flare.data.datasource.microblog.ListDataSource -import dev.dimension.flare.data.datasource.microblog.ListMetaData -import dev.dimension.flare.data.datasource.microblog.ListMetaDataType -import dev.dimension.flare.data.datasource.microblog.MemoryPagingSource import dev.dimension.flare.data.datasource.microblog.NotificationFilter import dev.dimension.flare.data.datasource.microblog.ProfileAction 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.memoryPager +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.pagingConfig import dev.dimension.flare.data.datasource.microblog.relationKeyWithUserKey import dev.dimension.flare.data.datasource.microblog.timelinePager @@ -115,7 +97,6 @@ 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.UiUserV2 import dev.dimension.flare.ui.model.mapper.bskyJson import dev.dimension.flare.ui.model.mapper.parseBskyFacets import dev.dimension.flare.ui.model.mapper.render @@ -146,7 +127,6 @@ import sh.christian.ozone.api.RKey import sh.christian.ozone.api.model.JsonContent import sh.christian.ozone.api.model.JsonContent.Companion.encodeAsJsonContent import kotlin.time.Clock -import kotlin.uuid.Uuid @OptIn(ExperimentalPagingApi::class) internal class BlueskyDataSource( @@ -238,7 +218,7 @@ internal class BlueskyDataSource( override val supportedNotificationFilter: List get() = listOf(NotificationFilter.All) - override fun userByAcct(acct: String): CacheData { + override fun userByAcct(acct: String): CacheData { val (name, host) = MicroBlogKey.valueOf(acct) return Cacheable( fetchSource = { @@ -999,7 +979,7 @@ internal class BlueskyDataSource( override fun searchUser( query: String, pageSize: Int, - ): Flow> = + ): Flow> = Pager( config = pagingConfig, ) { @@ -1010,7 +990,7 @@ internal class BlueskyDataSource( ) }.flow - override fun discoverUsers(pageSize: Int): Flow> = + override fun discoverUsers(pageSize: Int): Flow> = Pager( config = pagingConfig, ) { @@ -1081,67 +1061,34 @@ internal class BlueskyDataSource( }, ) - private val preferences: MemCacheable> by lazy { - MemCacheable( - key = "preferences_$accountKey", - ) { - service - .getPreferencesForActor() - .maybeResponse() - ?.preferences - .orEmpty() - } - } - private val myFeedsKey = "my_feeds_$accountKey" - val myFeeds: CacheData> by lazy { - MemCacheable( - key = myFeedsKey, - ) { - val preferences = - service - .getPreferencesForActor() - .maybeResponse() - ?.preferences - .orEmpty() - val items = - preferences - .filterIsInstance() - .firstOrNull() - ?.value - ?.items - ?.filter { - it.type == SavedFeedType.Feed - }.orEmpty() - service - .getFeedGenerators( - GetFeedGeneratorsQueryParams( - feeds = - items - .map { AtUri(it.value) } - .toImmutableList(), - ), - ).maybeResponse() - ?.feeds - ?.map { - it.render(accountKey) - }.orEmpty() - .toImmutableList() - } + internal val feedLoader by lazy { + BlueskyFeedLoader( + service = service, + accountKey = accountKey, + ) + } + + val feedHandler by lazy { + ListHandler( + pagingKey = myFeedsKey, + accountKey = accountKey, + loader = feedLoader, + ) } fun popularFeeds( query: String?, scope: CoroutineScope, - ): Flow>> = + ): Flow>> = Pager( config = pagingConfig, ) { - object : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null + object : BasePagingSource() { + override fun getRefreshKey(state: PagingState): String? = null - override suspend fun doLoad(params: LoadParams): LoadResult { + override suspend fun doLoad(params: LoadParams): LoadResult { val result = service .getPopularFeedGeneratorsUnspecced( @@ -1168,7 +1115,7 @@ internal class BlueskyDataSource( .let { feeds -> combine( feeds, - MemCacheable.subscribe>(myFeedsKey), + feedHandler.cacheData, ) { popular, my -> popular.map { item -> item to my.any { it.id == item.id } @@ -1176,36 +1123,6 @@ internal class BlueskyDataSource( } }.cachedIn(scope) - private fun feedInfoKey(uri: String) = "feed_info_$uri" - - fun feedInfo(uri: String): MemCacheable = - MemCacheable( - key = feedInfoKey(uri), - ) { - service - .getFeedGenerator( - GetFeedGeneratorQueryParams( - feed = AtUri(uri), - ), - ).requireResponse() - .view - .render(accountKey) - } - - fun feedTimeline( - uri: String, - pageSize: Int = 20, - scope: CoroutineScope, - ): Flow> = - timelinePager( - pageSize = pageSize, - database = database, - scope = scope, - filterFlow = localFilterRepository.getFlow(forTimeline = true), - accountRepository = accountRepository, - mediator = feedTimelineLoader(uri), - ) - fun feedTimelineLoader(uri: String) = FeedTimelineRemoteMediator( service = service, @@ -1214,215 +1131,33 @@ internal class BlueskyDataSource( uri = uri, ) - suspend fun subscribeFeed(data: UiList) { - MemCacheable.updateWith>( - key = myFeedsKey, - ) { - (it + data).toImmutableList() - } + suspend fun subscribeFeed(data: UiList.Feed) { tryRun { - val currentPreferences = service.getPreferencesForActor().requireResponse() - val feedInfo = - service - .getFeedGenerator(GetFeedGeneratorQueryParams(feed = AtUri(data.id))) - .requireResponse() - val newPreferences = currentPreferences.preferences.toMutableList() - val prefIndex = newPreferences.indexOfFirst { it is PreferencesUnion.SavedFeedsPref } - if (prefIndex != -1) { - val pref = newPreferences[prefIndex] as PreferencesUnion.SavedFeedsPref - val newPref = - pref.value.copy( - saved = (pref.value.saved + feedInfo.view.uri).toImmutableList(), - pinned = (pref.value.pinned + feedInfo.view.uri).toImmutableList(), - ) - newPreferences[prefIndex] = PreferencesUnion.SavedFeedsPref(newPref) - } - val prefV2Index = - newPreferences.indexOfFirst { it is PreferencesUnion.SavedFeedsPrefV2 } - if (prefV2Index != -1) { - val pref = newPreferences[prefV2Index] as PreferencesUnion.SavedFeedsPrefV2 - val newPref = - pref.value.copy( - items = - ( - pref.value.items + - SavedFeed( - type = SavedFeedType.Feed, - value = feedInfo.view.uri.atUri, - pinned = true, - id = Uuid.random().toString(), - ) - ).toImmutableList(), - ) - newPreferences[prefV2Index] = PreferencesUnion.SavedFeedsPrefV2(newPref) - } - - service.putPreferences( - request = - PutPreferencesRequest( - preferences = newPreferences.toImmutableList(), - ), - ) - myFeeds.refresh() - }.onFailure { - MemCacheable.updateWith>( - key = myFeedsKey, - ) { - it.filterNot { item -> item.id == data.id }.toImmutableList() - } + feedLoader.subscribe(data.id) + feedHandler.insertToDatabase(data) } } - suspend fun unsubscribeFeed(data: UiList) { - MemCacheable.updateWith>( - key = myFeedsKey, - ) { - it.filterNot { item -> item.id == data.id }.toImmutableList() - } - tryRun { - val currentPreferences = service.getPreferencesForActor().requireResponse() - val feedInfo = - service - .getFeedGenerator(GetFeedGeneratorQueryParams(feed = AtUri(data.id))) - .requireResponse() - val newPreferences = currentPreferences.preferences.toMutableList() - val prefIndex = newPreferences.indexOfFirst { it is PreferencesUnion.SavedFeedsPref } - if (prefIndex != -1) { - val pref = newPreferences[prefIndex] as PreferencesUnion.SavedFeedsPref - val newPref = - pref.value.copy( - saved = - pref.value.saved - .filterNot { it == feedInfo.view.uri } - .toImmutableList(), - pinned = - pref.value.pinned - .filterNot { it == feedInfo.view.uri } - .toImmutableList(), - ) - newPreferences[prefIndex] = PreferencesUnion.SavedFeedsPref(newPref) - } - val prefV2Index = - newPreferences.indexOfFirst { it is PreferencesUnion.SavedFeedsPrefV2 } - if (prefV2Index != -1) { - val pref = newPreferences[prefV2Index] as PreferencesUnion.SavedFeedsPrefV2 - val newPref = - pref.value.copy( - items = - pref.value.items - .filterNot { it.value == feedInfo.view.uri.atUri } - .toImmutableList(), - ) - newPreferences[prefV2Index] = PreferencesUnion.SavedFeedsPrefV2(newPref) - } - service.putPreferences( - request = - PutPreferencesRequest( - preferences = newPreferences.toImmutableList(), - ), - ) - myFeeds.refresh() - }.onFailure { - MemCacheable.updateWith>( - key = myFeedsKey, - ) { - (it + data).toImmutableList() - } - } + suspend fun unsubscribeFeed(data: UiList.Feed) { + feedHandler.delete(data.id) } - suspend fun favouriteFeed(data: UiList) { - MemCacheable.update( - key = feedInfoKey(data.id), - value = - data.copy( - liked = !data.liked, - ), - ) - - tryRun { - val feedInfo = - service - .getFeedGenerator(GetFeedGeneratorQueryParams(feed = AtUri(data.id))) - .requireResponse() - val likedUri = - feedInfo.view.viewer - ?.like - ?.atUri - if (likedUri != null) { - deleteLikeRecord(likedUri) - } else { - createLikeRecord(cid = feedInfo.view.cid.cid, uri = feedInfo.view.uri.atUri) + suspend fun favouriteFeed(data: UiList.Feed) { + feedHandler.withDatabase { updataCallback -> + val newData = data.copy(liked = !data.liked) + updataCallback(newData) + tryRun { + if (newData.liked) { + feedLoader.favourite(data.id) + } else { + feedLoader.unfavourite(data.id) + } + }.onFailure { + updataCallback(data) } - }.onFailure { - MemCacheable.update( - key = feedInfoKey(data.id), - value = - data.copy( - liked = data.liked, - ), - ) } } - private val myListKey = "my_list_$accountKey" - - override fun myList(scope: CoroutineScope): Flow> = - memoryPager( - pageSize = 20, - pagingKey = myListKey, - scope = scope, - mediator = - object : BaseRemoteMediator() { - var cursor: String? = null - - override suspend fun doLoad( - loadType: LoadType, - state: PagingState, - ): MediatorResult { - val result = - service - .getLists( - params = - GetListsQueryParams( - actor = Did(did = accountKey.id), - cursor = cursor, - ), - ).requireResponse() - val items = - result - .lists - .map { - it.render(accountKey) - }.toImmutableList() - cursor = result.cursor - MemoryPagingSource.update( - key = myListKey, - value = items, - ) - return MediatorResult.Success( - endOfPaginationReached = cursor == null, - ) - } - }, - ) - - private fun listInfoKey(uri: String) = "list_info_$uri" - - override fun listInfo(listId: String): MemCacheable = - MemCacheable( - key = listInfoKey(listId), - ) { - service - .getList( - GetListQueryParams( - list = AtUri(listId), - ), - ).requireResponse() - .list - .render(accountKey) - } - override fun listTimeline(listId: String) = ListTimelineRemoteMediator( service = service, @@ -1431,395 +1166,38 @@ internal class BlueskyDataSource( uri = listId, ) - private suspend fun createList( - title: String, - description: String?, - icon: FileItem?, - ) { - tryRun { - val iconInfo = - if (icon != null) { - service.uploadBlob(icon.readBytes()).maybeResponse() - } else { - null - } - val record = - app.bsky.graph.List( - purpose = app.bsky.graph.Token.Curatelist, - name = title, - description = description, - avatar = iconInfo?.blob, - createdAt = Clock.System.now(), - ) - service.createRecord( - request = - CreateRecordRequest( - repo = Did(did = accountKey.id), - collection = Nsid("app.bsky.graph.list"), - record = record.bskyJson(), - ), - ) - }.onSuccess { - val uri = it.requireResponse().uri - service - .getList( - params = - GetListQueryParams( - list = uri, - ), - ).requireResponse() - .list - .render(accountKey) - .let { list -> - MemoryPagingSource.updateWith( - key = myListKey, - ) { - (listOf(list) + it).toImmutableList() - } - } - } - } - - override suspend fun deleteList(listId: String) { - tryRun { - val id = listId.substringAfterLast('/') - service.applyWrites( - request = - ApplyWritesRequest( - repo = Did(did = accountKey.id), - writes = - persistentListOf( - ApplyWritesRequestWriteUnion.Delete( - value = - ApplyWritesDelete( - collection = Nsid("app.bsky.graph.list"), - rkey = RKey(id), - ), - ), - ), - ), - ) - }.onSuccess { - MemoryPagingSource.updateWith( - key = myListKey, - ) { - it.filterNot { item -> item.id == listId }.toImmutableList() - } - } - } - - private suspend fun updateList( - uri: String, - title: String, - description: String?, - icon: FileItem?, - ) { - tryRun { - val currentInfo: app.bsky.graph.List = - service - .getRecord( - params = - com.atproto.repo.GetRecordQueryParams( - collection = Nsid("app.bsky.graph.list"), - repo = Did(did = accountKey.id), - rkey = RKey(uri.substringAfterLast('/')), - ), - ).requireResponse() - .value - .decodeAs() - - val iconInfo = - if (icon != null) { - service.uploadBlob(icon.readBytes()).maybeResponse() - } else { - null - } - val newRecord = - currentInfo - .copy( - name = title, - description = description, - ).let { - if (iconInfo != null) { - it.copy(avatar = iconInfo.blob) - } else { - it - } - } - service.putRecord( - request = - PutRecordRequest( - repo = Did(did = accountKey.id), - collection = Nsid("app.bsky.graph.list"), - rkey = RKey(uri.substringAfterLast('/')), - record = newRecord.bskyJson(), - ), - ) - }.onSuccess { - MemoryPagingSource.updateWith( - key = myListKey, - ) { - it - .map { item -> - if (item.id == uri) { - item.copy( - title = title, - description = description, - ) - } else { - item - } - }.toImmutableList() - } - } - } - - private fun listMemberKey(listId: String) = "listMembers_$listId" - - override fun listMembers( - listId: String, - scope: CoroutineScope, - pageSize: Int, - ): Flow> = - memoryPager( - pageSize = pageSize, - pagingKey = listMemberKey(listId), - scope = scope, - mediator = - object : BaseRemoteMediator() { - private var cursor: String? = null - - override suspend fun doLoad( - loadType: LoadType, - state: PagingState, - ): MediatorResult { - if (loadType == LoadType.PREPEND) { - return MediatorResult.Success(endOfPaginationReached = true) - } - if (loadType == LoadType.REFRESH) { - cursor = null - } - val response = - service - .getList( - params = - GetListQueryParams( - list = AtUri(listId), - cursor = cursor, - limit = state.config.pageSize.toLong(), - ), - ).maybeResponse() - cursor = response?.cursor - val result = - response - ?.items - ?.map { - it.subject.render(accountKey) - } ?: emptyList() - - if (loadType == LoadType.REFRESH) { - MemoryPagingSource.update( - key = listMemberKey(listId), - value = result.toImmutableList(), - ) - } else if (loadType == LoadType.APPEND) { - MemoryPagingSource.append( - key = listMemberKey(listId), - value = result.toImmutableList(), - ) - } + private val myListKey = "my_list_$accountKey" - return MediatorResult.Success( - endOfPaginationReached = cursor == null, - ) - } - }, + val listLoader: ListLoader by lazy { + BlueskyListLoader( + service = service, + accountKey = accountKey, ) - - override suspend fun addMember( - listId: String, - userKey: MicroBlogKey, - ) { - tryRun { - val user = - service - .getProfile(GetProfileQueryParams(actor = Did(did = userKey.id))) - .requireResponse() - .render(accountKey) - - MemoryPagingSource.updateWith( - key = listMemberKey(listId), - ) { - (listOf(user) + it) - .distinctBy { - it.key - }.toImmutableList() - } - val list = - service - .getList( - params = - GetListQueryParams( - list = AtUri(listId), - ), - ).requireResponse() - .list - .render(accountKey) - MemCacheable.updateWith>(userListsKey(userKey)) { - (it + list).toImmutableList() - } - service.createRecord( - CreateRecordRequest( - repo = Did(did = accountKey.id), - collection = Nsid("app.bsky.graph.listitem"), - record = - app.bsky.graph - .Listitem( - list = AtUri(listId), - subject = Did(userKey.id), - createdAt = Clock.System.now(), - ).bskyJson(), - ), - ) - } } - override suspend fun removeMember( - listId: String, - userKey: MicroBlogKey, - ) { - tryRun { - MemoryPagingSource.updateWith( - key = listMemberKey(listId), - ) { - it - .filter { user -> user.key.id != userKey.id } - .toImmutableList() - } - MemCacheable.updateWith>(userListsKey(userKey)) { - it - .filter { list -> list.id != listId } - .toImmutableList() - } - var record: ListRecordsRecord? = null - var cursor: String? = null - while (record == null) { - val response = - service - .listRecords( - params = - ListRecordsQueryParams( - repo = Did(did = accountKey.id), - collection = Nsid("app.bsky.graph.listitem"), - limit = 100, - cursor = cursor, - ), - ).requireResponse() - if (response.cursor == null || response.records.isEmpty()) { - break - } - cursor = response.cursor - record = - response.records - .firstOrNull { - val item: app.bsky.graph.Listitem = it.value.decodeAs() - item.list.atUri == listId && item.subject.did == userKey.id - } - } - if (record != null) { - service.deleteRecord( - DeleteRecordRequest( - repo = Did(did = accountKey.id), - collection = Nsid("app.bsky.graph.listitem"), - rkey = RKey(record.uri.atUri.substringAfterLast('/')), - ), - ) - } - } + val listMemberLoader: ListMemberLoader by lazy { + BlueskyListMemberLoader( + service = service, + accountKey = accountKey, + ) } - override val supportedMetaData: ImmutableList - get() = - persistentListOf( - ListMetaDataType.TITLE, - ListMetaDataType.DESCRIPTION, - ListMetaDataType.AVATAR, - ) - - override suspend fun updateList( - listId: String, - metaData: ListMetaData, - ) { - updateList( - uri = listId, - title = metaData.title, - description = metaData.description, - icon = metaData.avatar, + override val listHandler: ListHandler by lazy { + ListHandler( + pagingKey = myListKey, + accountKey = accountKey, + loader = listLoader, ) } - override suspend fun createList(metaData: ListMetaData) { - createList( - title = metaData.title, - description = metaData.description, - icon = metaData.avatar, + override val listMemberHandler: ListMemberHandler by lazy { + ListMemberHandler( + pagingKey = "list_members_$accountKey", + accountKey = accountKey, + loader = listMemberLoader, ) } - override fun listMemberCache(listId: String): Flow> = - MemoryPagingSource.getFlow(listMemberKey(listId)) - - private fun userListsKey(userKey: MicroBlogKey) = "userLists_${userKey.id}" - - override fun userLists(userKey: MicroBlogKey): MemCacheable> = - MemCacheable( - key = userListsKey(userKey), - ) { - var cursor: String? = null - val lists = mutableListOf() - val allLists = - service - .getLists( - params = - GetListsQueryParams( - actor = Did(did = accountKey.id), - limit = 100, - ), - ).requireResponse() - .lists - .map { - it.render(accountKey) - } - while (true) { - val response = - service - .listRecords( - params = - ListRecordsQueryParams( - repo = Did(did = accountKey.id), - collection = Nsid("app.bsky.graph.listitem"), - limit = 100, - cursor = cursor, - ), - ).requireResponse() - lists.addAll( - response.records - .filter { - val item: Listitem = it.value.decodeAs() - item.subject.did == userKey.id - }.mapNotNull { - val item: Listitem = it.value.decodeAs() - allLists.firstOrNull { it.id == item.list.atUri } - }, - ) - cursor = response.cursor - if (cursor == null) { - break - } - } - lists.toImmutableList() - } - private val notificationMarkerKey: String get() = "notificationBadgeCount_$accountKey" @@ -2199,7 +1577,7 @@ internal class BlueskyDataSource( userKey: MicroBlogKey, scope: CoroutineScope, pageSize: Int, - ): Flow> = + ): Flow> = Pager( config = pagingConfig, ) { @@ -2214,7 +1592,7 @@ internal class BlueskyDataSource( userKey: MicroBlogKey, scope: CoroutineScope, pageSize: Int, - ): Flow> = + ): Flow> = Pager( config = pagingConfig, ) { 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 new file mode 100644 index 000000000..933f46f00 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyFeedLoader.kt @@ -0,0 +1,267 @@ +package dev.dimension.flare.data.datasource.bluesky + +import app.bsky.actor.PreferencesUnion +import app.bsky.actor.PutPreferencesRequest +import app.bsky.actor.SavedFeed +import app.bsky.actor.SavedFeedType +import app.bsky.feed.GetFeedGeneratorQueryParams +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.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.mapper.render +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import sh.christian.ozone.api.AtUri +import sh.christian.ozone.api.Cid +import sh.christian.ozone.api.Did +import sh.christian.ozone.api.Nsid +import sh.christian.ozone.api.RKey +import kotlin.time.Clock +import kotlin.uuid.Uuid + +internal class BlueskyFeedLoader( + private val service: BlueskyService, + private val accountKey: MicroBlogKey, +) : ListLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + val cursor = + when (request) { + is PagingRequest.Append -> request.nextKey + is PagingRequest.Refresh -> null + is PagingRequest.Prepend -> return PagingResult() + } + + // Feed loading usually doesn't support pagination in the same way lists do via preferences + // But we can implement a basic fetch. + // Bluesky preferences don't really support pagination for saved feeds. + // We'll load all if cursor is null, otherwise return empty (end of list). + if (cursor != null) { + return PagingResult( + data = persistentListOf(), + nextKey = null, + ) + } + + val preferences = + service + .getPreferencesForActor() + .requireResponse() + .preferences + + val items = + preferences + .filterIsInstance() + .firstOrNull() + ?.value + ?.items + ?.filter { + it.type == SavedFeedType.Feed + }.orEmpty() + + val feeds = + service + .getFeedGenerators( + GetFeedGeneratorsQueryParams( + feeds = + items + .map { AtUri(it.value) } + .toImmutableList(), + ), + ).requireResponse() + .feeds + .map { + it.render(accountKey) + }.toImmutableList() + + return PagingResult( + data = feeds, + nextKey = null, + ) + } + + override suspend fun info(listId: String): UiList = + service + .getFeedGenerator( + GetFeedGeneratorQueryParams( + feed = AtUri(listId), + ), + ).requireResponse() + .view + .render(accountKey) + + override val supportedMetaData: ImmutableList + get() = persistentListOf() + + override suspend fun create(metaData: ListMetaData): UiList = throw UnsupportedOperationException("Create feed is not supported") + + override suspend fun update( + listId: String, + metaData: ListMetaData, + ): UiList = throw UnsupportedOperationException("Update feed is not supported") + + override suspend fun delete(listId: String) { + val currentPreferences = service.getPreferencesForActor().requireResponse() + val feedInfo = + service + .getFeedGenerator(GetFeedGeneratorQueryParams(feed = AtUri(listId))) + .requireResponse() + val newPreferences = currentPreferences.preferences.toMutableList() + val prefIndex = newPreferences.indexOfFirst { it is PreferencesUnion.SavedFeedsPref } + if (prefIndex != -1) { + val pref = newPreferences[prefIndex] as PreferencesUnion.SavedFeedsPref + val newPref = + pref.value.copy( + saved = + pref.value.saved + .filterNot { it == feedInfo.view.uri } + .toImmutableList(), + pinned = + pref.value.pinned + .filterNot { it == feedInfo.view.uri } + .toImmutableList(), + ) + newPreferences[prefIndex] = PreferencesUnion.SavedFeedsPref(newPref) + } + val prefV2Index = + newPreferences.indexOfFirst { it is PreferencesUnion.SavedFeedsPrefV2 } + if (prefV2Index != -1) { + val pref = newPreferences[prefV2Index] as PreferencesUnion.SavedFeedsPrefV2 + val newPref = + pref.value.copy( + items = + pref.value.items + .filterNot { it.value == feedInfo.view.uri.atUri } + .toImmutableList(), + ) + newPreferences[prefV2Index] = PreferencesUnion.SavedFeedsPrefV2(newPref) + } + service.putPreferences( + request = + PutPreferencesRequest( + preferences = newPreferences.toImmutableList(), + ), + ) + } + + suspend fun subscribe(feedUri: String) { + val currentPreferences = service.getPreferencesForActor().requireResponse() + val feedInfo = + service + .getFeedGenerator(GetFeedGeneratorQueryParams(feed = AtUri(feedUri))) + .requireResponse() + val newPreferences = currentPreferences.preferences.toMutableList() + val prefIndex = newPreferences.indexOfFirst { it is PreferencesUnion.SavedFeedsPref } + if (prefIndex != -1) { + val pref = newPreferences[prefIndex] as PreferencesUnion.SavedFeedsPref + val newPref = + pref.value.copy( + saved = (pref.value.saved + feedInfo.view.uri).toImmutableList(), + pinned = (pref.value.pinned + feedInfo.view.uri).toImmutableList(), + ) + newPreferences[prefIndex] = PreferencesUnion.SavedFeedsPref(newPref) + } + val prefV2Index = + newPreferences.indexOfFirst { it is PreferencesUnion.SavedFeedsPrefV2 } + if (prefV2Index != -1) { + val pref = newPreferences[prefV2Index] as PreferencesUnion.SavedFeedsPrefV2 + val newPref = + pref.value.copy( + items = + ( + pref.value.items + + SavedFeed( + type = SavedFeedType.Feed, + value = feedInfo.view.uri.atUri, + pinned = true, + id = Uuid.random().toString(), + ) + ).toImmutableList(), + ) + newPreferences[prefV2Index] = PreferencesUnion.SavedFeedsPrefV2(newPref) + } + + service.putPreferences( + request = + PutPreferencesRequest( + preferences = newPreferences.toImmutableList(), + ), + ) + } + + suspend fun favourite(feedUri: String) { + val feedInfo = + service + .getFeedGenerator(GetFeedGeneratorQueryParams(feed = AtUri(feedUri))) + .requireResponse() + val likedUri = + feedInfo.view.viewer + ?.like + ?.atUri + + // If already liked, technically we shouldn't be here if called correctly, or we treat as idempotent? + // User request said "favourite" and "unfavourite" separate methods. + if (likedUri == null) { + createLikeRecord(cid = feedInfo.view.cid.cid, uri = feedInfo.view.uri.atUri) + } + } + + suspend fun unfavourite(feedUri: String) { + val feedInfo = + service + .getFeedGenerator(GetFeedGeneratorQueryParams(feed = AtUri(feedUri))) + .requireResponse() + val likedUri = + feedInfo.view.viewer + ?.like + ?.atUri + + if (likedUri != null) { + deleteLikeRecord(likedUri) + } + } + + private suspend fun createLikeRecord( + cid: String, + uri: String, + ) { + service + .createRecord( + CreateRecordRequest( + repo = Did(did = accountKey.id), + collection = Nsid("app.bsky.feed.like"), + record = + app.bsky.feed + .Like( + subject = + StrongRef( + uri = AtUri(uri), + cid = Cid(cid), + ), + createdAt = Clock.System.now(), + ).bskyJson(), + ), + ).requireResponse() + } + + private suspend fun deleteLikeRecord(likedUri: String) = + service.deleteRecord( + DeleteRecordRequest( + repo = Did(did = accountKey.id), + collection = Nsid("app.bsky.feed.like"), + rkey = RKey(likedUri.substringAfterLast('/')), + ), + ) +} 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 new file mode 100644 index 000000000..1de8baf62 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyListLoader.kt @@ -0,0 +1,212 @@ +package dev.dimension.flare.data.datasource.bluesky + +import app.bsky.graph.GetListQueryParams +import app.bsky.graph.GetListsQueryParams +import com.atproto.repo.ApplyWritesDelete +import com.atproto.repo.ApplyWritesRequest +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.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.mapper.render +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import sh.christian.ozone.api.AtUri +import sh.christian.ozone.api.Did +import sh.christian.ozone.api.Nsid +import sh.christian.ozone.api.RKey +import kotlin.time.Clock + +internal class BlueskyListLoader( + private val service: BlueskyService, + private val accountKey: MicroBlogKey, +) : ListLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + val cursor = + when (request) { + is PagingRequest.Append -> request.nextKey + is PagingRequest.Refresh -> null + is PagingRequest.Prepend -> return PagingResult() + } + val result = + service + .getLists( + params = + GetListsQueryParams( + actor = Did(did = accountKey.id), + cursor = cursor, + limit = pageSize.toLong(), + ), + ).requireResponse() + val items = + result + .lists + .map { + it.render(accountKey) + }.toImmutableList() + return PagingResult( + data = items, + nextKey = result.cursor, + ) + } + + override suspend fun info(listId: String): UiList = + service + .getList( + GetListQueryParams( + list = AtUri(listId), + ), + ).requireResponse() + .list + .render(accountKey) + + override val supportedMetaData: ImmutableList + get() = + persistentListOf( + ListMetaDataType.TITLE, + ListMetaDataType.DESCRIPTION, + ListMetaDataType.AVATAR, + ) + + override suspend fun create(metaData: ListMetaData): UiList = + createList( + title = metaData.title, + description = metaData.description, + icon = metaData.avatar, + ) + + private suspend fun createList( + title: String, + description: String?, + icon: FileItem?, + ): UiList { + val iconInfo = + if (icon != null) { + service.uploadBlob(icon.readBytes()).maybeResponse() + } else { + null + } + val record = + app.bsky.graph.List( + purpose = app.bsky.graph.Token.Curatelist, + name = title, + description = description, + avatar = iconInfo?.blob, + createdAt = Clock.System.now(), + ) + val response = + service + .createRecord( + request = + CreateRecordRequest( + repo = Did(did = accountKey.id), + collection = Nsid("app.bsky.graph.list"), + record = record.bskyJson(), + ), + ).requireResponse() + + val uri = response.uri + return service + .getList( + params = + GetListQueryParams( + list = uri, + ), + ).requireResponse() + .list + .render(accountKey) + } + + override suspend fun update( + listId: String, + metaData: ListMetaData, + ): UiList { + updateList( + uri = listId, + title = metaData.title, + description = metaData.description, + icon = metaData.avatar, + ) + return info(listId) + } + + private suspend fun updateList( + uri: String, + title: String, + description: String?, + icon: FileItem?, + ) { + val currentInfo: app.bsky.graph.List = + service + .getRecord( + params = + com.atproto.repo.GetRecordQueryParams( + collection = Nsid("app.bsky.graph.list"), + repo = Did(did = accountKey.id), + rkey = RKey(uri.substringAfterLast('/')), + ), + ).requireResponse() + .value + .decodeAs() + + val iconInfo = + if (icon != null) { + service.uploadBlob(icon.readBytes()).maybeResponse() + } else { + null + } + val newRecord = + currentInfo + .copy( + name = title, + description = description, + ).let { + if (iconInfo != null) { + it.copy(avatar = iconInfo.blob) + } else { + it + } + } + service.putRecord( + request = + PutRecordRequest( + repo = Did(did = accountKey.id), + collection = Nsid("app.bsky.graph.list"), + rkey = RKey(uri.substringAfterLast('/')), + record = newRecord.bskyJson(), + ), + ) + } + + override suspend fun delete(listId: String) { + val id = listId.substringAfterLast('/') + service.applyWrites( + request = + ApplyWritesRequest( + repo = Did(did = accountKey.id), + writes = + persistentListOf( + ApplyWritesRequestWriteUnion.Delete( + value = + ApplyWritesDelete( + collection = Nsid("app.bsky.graph.list"), + rkey = RKey(id), + ), + ), + ), + ), + ) + } +} 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 new file mode 100644 index 000000000..19a904134 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyListMemberLoader.kt @@ -0,0 +1,187 @@ +package dev.dimension.flare.data.datasource.bluesky + +import app.bsky.actor.GetProfileQueryParams +import app.bsky.graph.GetListQueryParams +import app.bsky.graph.GetListsQueryParams +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.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.mapper.render +import sh.christian.ozone.api.AtUri +import sh.christian.ozone.api.Did +import sh.christian.ozone.api.Nsid +import sh.christian.ozone.api.RKey +import kotlin.time.Clock + +internal class BlueskyListMemberLoader( + private val service: BlueskyService, + private val accountKey: MicroBlogKey, +) : ListMemberLoader { + override suspend fun loadMembers( + pageSize: Int, + request: PagingRequest, + listId: String, + ): PagingResult { + val cursor = + when (request) { + is PagingRequest.Append -> request.nextKey + is PagingRequest.Refresh -> null + is PagingRequest.Prepend -> return PagingResult() + } + val response = + service + .getList( + params = + GetListQueryParams( + list = AtUri(listId), + cursor = cursor, + limit = pageSize.toLong(), + ), + ).requireResponse() + val users = + response.items + .map { + it.subject.toDbUser(accountKey.host) + } + return PagingResult( + data = users, + nextKey = response.cursor, + ) + } + + override suspend fun addMember( + listId: String, + userKey: MicroBlogKey, + ): DbUser { + val user = + service + .getProfile(GetProfileQueryParams(actor = Did(did = userKey.id))) + .requireResponse() + + service.createRecord( + CreateRecordRequest( + repo = Did(did = accountKey.id), + collection = Nsid("app.bsky.graph.listitem"), + record = + app.bsky.graph + .Listitem( + list = AtUri(listId), + subject = Did(userKey.id), + createdAt = Clock.System.now(), + ).bskyJson(), + ), + ) + return user.toDbUser(accountKey.host) + } + + override suspend fun removeMember( + listId: String, + userKey: MicroBlogKey, + ) { + var record: com.atproto.repo.ListRecordsRecord? = null + var cursor: String? = null + while (record == null) { + val response = + service + .listRecords( + params = + ListRecordsQueryParams( + repo = Did(did = accountKey.id), + collection = Nsid("app.bsky.graph.listitem"), + limit = 100, + cursor = cursor, + ), + ).requireResponse() + if (response.cursor == null || response.records.isEmpty()) { + break + } + cursor = response.cursor + record = + response.records + .firstOrNull { + val item: Listitem = it.value.decodeAs() + item.list.atUri == listId && item.subject.did == userKey.id + } + } + if (record != null) { + service.deleteRecord( + DeleteRecordRequest( + repo = Did(did = accountKey.id), + collection = Nsid("app.bsky.graph.listitem"), + rkey = RKey(record.uri.atUri.substringAfterLast('/')), + ), + ) + } + } + + override suspend fun loadUserLists( + pageSize: Int, + request: PagingRequest, + userKey: MicroBlogKey, + ): PagingResult { + if (request is PagingRequest.Prepend) { + return PagingResult() + } + // Bluesky doesn't have an endpoint to get lists a user is in, so we have to iterate through all lists + // Since we can't easily pagination this, we will load everything at once for the first page + if (request is PagingRequest.Append) { + return PagingResult(nextKey = null) + } + + var cursor: String? = null + val lists = mutableListOf() + val allLists = + service + .getLists( + params = + GetListsQueryParams( + actor = Did(did = accountKey.id), + limit = 100, + ), + ).requireResponse() + .lists + .map { + it.render(accountKey) + } + while (true) { + val response = + service + .listRecords( + params = + ListRecordsQueryParams( + repo = Did(did = accountKey.id), + collection = Nsid("app.bsky.graph.listitem"), + limit = 100, + cursor = cursor, + ), + ).requireResponse() + lists.addAll( + response.records + .filter { + val item: Listitem = it.value.decodeAs() + item.subject.did == userKey.id + }.mapNotNull { + val item: Listitem = it.value.decodeAs() + allLists.firstOrNull { list -> list.id == item.list.atUri } + }, + ) + cursor = response.cursor + if (cursor == null) { + break + } + } + return PagingResult( + data = lists, + nextKey = null, + ) + } +} 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 a23aea899..67dbbdf14 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,9 +2,12 @@ package dev.dimension.flare.data.datasource.bluesky import androidx.paging.ExperimentalPagingApi import app.bsky.bookmark.GetBookmarksQueryParams -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.bluesky.BlueskyService import dev.dimension.flare.model.MicroBlogKey @@ -20,11 +23,11 @@ internal class BookmarkTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .getBookmarks( GetBookmarksQueryParams( @@ -33,13 +36,13 @@ internal class BookmarkTimelineRemoteMediator( ).requireResponse() } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service .getBookmarks( GetBookmarksQueryParams( @@ -50,7 +53,7 @@ internal class BookmarkTimelineRemoteMediator( } } - return Result( + return PagingResult( endOfPaginationReached = response.bookmarks.isEmpty() || response.cursor == null, data = response.bookmarks.toDb( 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 cc223dd5d..074a1c19f 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 @@ -5,7 +5,7 @@ import app.bsky.graph.GetFollowersQueryParams import dev.dimension.flare.common.BasePagingSource import dev.dimension.flare.data.network.bluesky.BlueskyService import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiUserV2 +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.mapper.render import sh.christian.ozone.api.Did @@ -13,10 +13,10 @@ internal class FansPagingSource( private val service: BlueskyService, private val accountKey: MicroBlogKey, private val userKey: MicroBlogKey, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null +) : BasePagingSource() { + override fun getRefreshKey(state: PagingState): String? = null - override suspend fun doLoad(params: LoadParams): LoadResult { + override suspend fun doLoad(params: LoadParams): LoadResult { val cursor = params.key val limit = params.loadSize val response = 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 af48bf349..c9a575767 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,9 +2,12 @@ package dev.dimension.flare.data.datasource.bluesky import androidx.paging.ExperimentalPagingApi import app.bsky.feed.GetFeedQueryParams -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 sh.christian.ozone.api.AtUri @@ -22,11 +25,11 @@ internal class FeedTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> + PagingRequest.Refresh -> service .getFeed( GetFeedQueryParams( @@ -35,13 +38,13 @@ internal class FeedTimelineRemoteMediator( ), ).maybeResponse() - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service .getFeed( GetFeedQueryParams( @@ -51,11 +54,11 @@ internal class FeedTimelineRemoteMediator( ), ).maybeResponse() } - } ?: return Result( + } ?: return PagingResult( endOfPaginationReached = true, ) - return Result( + return PagingResult( endOfPaginationReached = response.cursor == null, data = response.feed.toDbPagingTimeline( 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 deac658dc..2bcbcf649 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 @@ -5,7 +5,7 @@ import app.bsky.graph.GetFollowsQueryParams import dev.dimension.flare.common.BasePagingSource import dev.dimension.flare.data.network.bluesky.BlueskyService import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiUserV2 +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.mapper.render import sh.christian.ozone.api.Did @@ -13,10 +13,10 @@ internal class FollowingPagingSource( private val service: BlueskyService, private val accountKey: MicroBlogKey, private val userKey: MicroBlogKey, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null +) : BasePagingSource() { + override fun getRefreshKey(state: PagingState): String? = null - override suspend fun doLoad(params: LoadParams): LoadResult { + override suspend fun doLoad(params: LoadParams): LoadResult { val cursor = params.key val limit = params.loadSize val response = 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 ce797734a..24e5eaffb 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,11 +2,14 @@ package dev.dimension.flare.data.datasource.bluesky import androidx.paging.ExperimentalPagingApi import app.bsky.feed.GetTimelineQueryParams -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -27,15 +30,15 @@ internal class HomeTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - is Request.Prepend -> return Result( + is PagingRequest.Prepend -> return PagingResult( endOfPaginationReached = true, ) - Request.Refresh -> { + PagingRequest.Refresh -> { service .getTimeline( GetTimelineQueryParams( @@ -44,7 +47,7 @@ internal class HomeTimelineRemoteMediator( ).maybeResponse() } - is Request.Append -> { + is PagingRequest.Append -> { service .getTimeline( GetTimelineQueryParams( @@ -53,10 +56,10 @@ internal class HomeTimelineRemoteMediator( ), ).maybeResponse() } - } ?: return Result( + } ?: return PagingResult( endOfPaginationReached = true, ) - return Result( + return PagingResult( endOfPaginationReached = response.cursor == null, data = response.feed.toDbPagingTimeline( 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 077f6b328..ca81b15fa 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,9 +2,12 @@ package dev.dimension.flare.data.datasource.bluesky import androidx.paging.ExperimentalPagingApi import app.bsky.feed.GetListFeedQueryParams -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 sh.christian.ozone.api.AtUri @@ -22,11 +25,11 @@ internal class ListTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> + PagingRequest.Refresh -> service .getListFeed( GetListFeedQueryParams( @@ -35,13 +38,13 @@ internal class ListTimelineRemoteMediator( ), ).maybeResponse() - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service .getListFeed( GetListFeedQueryParams( @@ -51,11 +54,11 @@ internal class ListTimelineRemoteMediator( ), ).maybeResponse() } - } ?: return Result( + } ?: return PagingResult( endOfPaginationReached = true, ) - return Result( + return PagingResult( endOfPaginationReached = response.cursor == null, data = response.feed.toDbPagingTimeline( 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 26e4d9166..0ccc8f05d 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,9 +7,12 @@ import app.bsky.feed.Repost import app.bsky.notification.ListNotificationsNotificationReason import app.bsky.notification.ListNotificationsQueryParams import app.bsky.notification.UpdateSeenRequest -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 kotlinx.collections.immutable.toImmutableList @@ -33,11 +36,11 @@ internal class NotificationRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .listNotifications( ListNotificationsQueryParams( @@ -55,7 +58,7 @@ internal class NotificationRemoteMediator( } } - is Request.Append -> { + is PagingRequest.Append -> { service .listNotifications( ListNotificationsQueryParams( @@ -66,11 +69,11 @@ internal class NotificationRemoteMediator( } else -> { - return Result( + return PagingResult( endOfPaginationReached = true, ) } - } ?: return Result( + } ?: return PagingResult( endOfPaginationReached = true, ) @@ -111,7 +114,7 @@ internal class NotificationRemoteMediator( .orEmpty() .associateBy { it.uri } .toImmutableMap() - return Result( + return PagingResult( endOfPaginationReached = response.cursor == null, data = response.notifications.toDb( 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 e20b6583b..c43ce26bb 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,9 +2,12 @@ package dev.dimension.flare.data.datasource.bluesky import androidx.paging.ExperimentalPagingApi import app.bsky.feed.SearchPostsQueryParams -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.bluesky.BlueskyService import dev.dimension.flare.model.MicroBlogKey @@ -26,16 +29,16 @@ internal class SearchStatusRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - Request.Refresh -> { + PagingRequest.Refresh -> { service.searchPosts( SearchPostsQueryParams( q = query, @@ -44,7 +47,7 @@ internal class SearchStatusRemoteMediator( ) } - is Request.Append -> { + is PagingRequest.Append -> { service.searchPosts( SearchPostsQueryParams( q = query, @@ -55,7 +58,7 @@ internal class SearchStatusRemoteMediator( } }.requireResponse() - return Result( + return PagingResult( endOfPaginationReached = response.cursor == null, data = response.posts.toDb( 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 3a3378663..93dad5e1c 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 @@ -5,17 +5,17 @@ import app.bsky.actor.SearchActorsQueryParams import dev.dimension.flare.common.BasePagingSource import dev.dimension.flare.data.network.bluesky.BlueskyService import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiUserV2 +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.mapper.render internal class SearchUserPagingSource( private val service: BlueskyService, private val accountKey: MicroBlogKey, private val query: String, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null +) : BasePagingSource() { + override fun getRefreshKey(state: PagingState): String? = null - override suspend fun doLoad(params: LoadParams): LoadResult { + override suspend fun doLoad(params: LoadParams): LoadResult { service .searchActors( SearchActorsQueryParams(q = query, limit = params.loadSize.toLong(), cursor = params.key), 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 f38b786f5..9dd7b3e6a 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,10 +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.common.BaseTimelineRemoteMediator 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.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 @@ -45,13 +48,13 @@ internal class StatusDetailRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val result = when (request) { - is Request.Append -> { + is PagingRequest.Append -> { if (statusOnly) { - return Result( + return PagingResult( endOfPaginationReached = true, ) } else { @@ -129,12 +132,12 @@ internal class StatusDetailRemoteMediator( } } } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - Request.Refresh -> { + PagingRequest.Refresh -> { if (!database.pagingTimelineDao().existsPaging(accountKey, pagingKey)) { database.statusDao().get(statusKey, AccountType.Specific(accountKey)).firstOrNull()?.let { database @@ -165,8 +168,8 @@ internal class StatusDetailRemoteMediator( } } - val shouldLoadMore = !(request is Request.Append || statusOnly) - return Result( + val shouldLoadMore = !(request is PagingRequest.Append || statusOnly) + return PagingResult( endOfPaginationReached = !shouldLoadMore, data = result.toDbPagingTimeline( 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 3f99bd285..bafea783d 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 @@ -5,16 +5,16 @@ import app.bsky.actor.GetSuggestionsQueryParams import dev.dimension.flare.common.BasePagingSource import dev.dimension.flare.data.network.bluesky.BlueskyService import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiUserV2 +import dev.dimension.flare.ui.model.UiProfile 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 +) : BasePagingSource() { + override fun getRefreshKey(state: PagingState): String? = null - override suspend fun doLoad(params: LoadParams): LoadResult { + override suspend fun doLoad(params: LoadParams): LoadResult { val response = service .getSuggestions(GetSuggestionsQueryParams(limit = params.loadSize.toLong(), cursor = params.key)) 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 872b39dfa..76041f6c0 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,9 +2,12 @@ package dev.dimension.flare.data.datasource.bluesky import androidx.paging.ExperimentalPagingApi import app.bsky.feed.GetActorLikesQueryParams -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 sh.christian.ozone.api.Did @@ -21,11 +24,11 @@ internal class UserLikesTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> + PagingRequest.Refresh -> service .getActorLikes( GetActorLikesQueryParams( @@ -34,13 +37,13 @@ internal class UserLikesTimelineRemoteMediator( ), ).maybeResponse() - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service .getActorLikes( GetActorLikesQueryParams( @@ -50,11 +53,11 @@ internal class UserLikesTimelineRemoteMediator( ), ).maybeResponse() } - } ?: return Result( + } ?: return PagingResult( endOfPaginationReached = true, ) - return Result( + return PagingResult( endOfPaginationReached = response.cursor == null, data = response.feed.toDbPagingTimeline( 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 e154e1d53..593e16555 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,9 +3,12 @@ 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.common.BaseTimelineRemoteMediator 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.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 sh.christian.ozone.api.Did @@ -36,8 +39,8 @@ internal class UserTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val filter = when { onlyMedia -> GetAuthorFeedFilter.PostsWithMedia @@ -46,7 +49,7 @@ internal class UserTimelineRemoteMediator( } val response = when (request) { - Request.Refresh -> + PagingRequest.Refresh -> service .getAuthorFeed( GetAuthorFeedQueryParams( @@ -57,13 +60,13 @@ internal class UserTimelineRemoteMediator( ), ).maybeResponse() - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service .getAuthorFeed( GetAuthorFeedQueryParams( @@ -75,11 +78,11 @@ internal class UserTimelineRemoteMediator( ), ).maybeResponse() } - } ?: return Result( + } ?: return PagingResult( endOfPaginationReached = true, ) - return Result( + return PagingResult( endOfPaginationReached = response.cursor == null, data = response.feed.toDbPagingTimeline( 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 ee43c6936..8e93aba32 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 @@ -3,7 +3,6 @@ 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.BaseTimelinePagingSourceFactory import dev.dimension.flare.common.CacheData import dev.dimension.flare.common.Cacheable import dev.dimension.flare.common.MemCacheable @@ -16,6 +15,7 @@ import dev.dimension.flare.data.datasource.mastodon.SearchUserPagingSource import dev.dimension.flare.data.datasource.mastodon.TrendHashtagPagingSource import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource 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.network.mastodon.GuestMastodonService import dev.dimension.flare.model.MicroBlogKey @@ -23,7 +23,6 @@ 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.UiUserV2 import dev.dimension.flare.ui.model.mapper.render import dev.dimension.flare.ui.model.mapper.renderGuest import kotlinx.collections.immutable.ImmutableList @@ -50,7 +49,7 @@ internal class GuestMastodonDataSource( GuestTimelinePagingSource(host = host, service = service) } - override fun userByAcct(acct: String): CacheData { + override fun userByAcct(acct: String): CacheData { val (name, host) = MicroBlogKey.valueOf(acct) return Cacheable( fetchSource = { @@ -144,7 +143,7 @@ internal class GuestMastodonDataSource( override fun searchUser( query: String, pageSize: Int, - ): Flow> = + ): Flow> = Pager( config = pagingConfig, ) { @@ -156,7 +155,7 @@ internal class GuestMastodonDataSource( ) }.flow - override fun discoverUsers(pageSize: Int): Flow> { + override fun discoverUsers(pageSize: Int): Flow> { // not supported throw UnsupportedOperationException("Discover users not supported") } @@ -179,7 +178,7 @@ internal class GuestMastodonDataSource( userKey: MicroBlogKey, scope: CoroutineScope, pageSize: Int, - ): Flow> = + ): Flow> = Pager( config = pagingConfig, ) { @@ -195,7 +194,7 @@ internal class GuestMastodonDataSource( userKey: MicroBlogKey, scope: CoroutineScope, pageSize: Int, - ): Flow> = + ): Flow> = Pager( config = pagingConfig, ) { 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 331d4620f..dcde86c39 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 @@ -2,9 +2,12 @@ package dev.dimension.flare.data.datasource.mastodon import SnowflakeIdGenerator import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.mastodon.MastodonService import dev.dimension.flare.model.MicroBlogKey @@ -20,24 +23,24 @@ internal class BookmarkTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .bookmarks( limit = pageSize, ) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.bookmarks( limit = pageSize, max_id = request.nextKey, @@ -45,7 +48,7 @@ internal class BookmarkTimelineRemoteMediator( } } - return Result( + return PagingResult( endOfPaginationReached = response.isEmpty() || response.next == null, data = response.toDbPagingTimeline( 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 b1b2219af..ce429243c 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.mastodon import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.mastodon.MastodonService import dev.dimension.flare.model.MicroBlogKey @@ -19,22 +22,22 @@ internal class DiscoverStatusRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service.trendsStatuses() } else -> { - return Result( + return PagingResult( endOfPaginationReached = true, ) } } - return Result( + return PagingResult( endOfPaginationReached = true, data = response.toDbPagingTimeline( 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 017d3b474..829afb139 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.mastodon import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.mastodon.MastodonService import dev.dimension.flare.model.MicroBlogKey @@ -19,24 +22,24 @@ internal class FavouriteTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .favorites( limit = pageSize, ) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.favorites( limit = pageSize, max_id = request.nextKey, @@ -44,7 +47,7 @@ internal class FavouriteTimelineRemoteMediator( } } - return Result( + return PagingResult( endOfPaginationReached = response.isEmpty() || response.next == null, data = response.toDbPagingTimeline( 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 6ae953376..2177a747b 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.mastodon import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.mastodon.MastodonService import dev.dimension.flare.model.MicroBlogKey @@ -21,25 +24,25 @@ internal class HomeTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .homeTimeline( limit = pageSize, ) } - is Request.Prepend -> { + is PagingRequest.Prepend -> { service.homeTimeline( limit = pageSize, min_id = request.previousKey, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.homeTimeline( limit = pageSize, max_id = request.nextKey, @@ -47,7 +50,7 @@ internal class HomeTimelineRemoteMediator( } } - return Result( + return PagingResult( endOfPaginationReached = response.isEmpty(), data = response.toDbPagingTimeline( 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 7050aec90..03f6c3bcf 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.mastodon import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.mastodon.MastodonService import dev.dimension.flare.model.MicroBlogKey @@ -20,11 +23,11 @@ internal class ListTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .listTimeline( listId = listId, @@ -32,13 +35,13 @@ internal class ListTimelineRemoteMediator( ) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.listTimeline( listId = listId, limit = pageSize, @@ -47,7 +50,7 @@ internal class ListTimelineRemoteMediator( } } - return Result( + return PagingResult( endOfPaginationReached = response.isEmpty(), data = response.toDbPagingTimeline( 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 13f695bf7..37d108f14 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,12 +1,9 @@ package dev.dimension.flare.data.datasource.mastodon import androidx.paging.ExperimentalPagingApi -import androidx.paging.LoadType import androidx.paging.Pager import androidx.paging.PagingData -import androidx.paging.PagingState import androidx.paging.cachedIn -import dev.dimension.flare.common.BaseRemoteMediator import dev.dimension.flare.common.CacheData import dev.dimension.flare.common.Cacheable import dev.dimension.flare.common.FileType @@ -23,23 +20,21 @@ 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.ListDataSource -import dev.dimension.flare.data.datasource.microblog.ListMetaData -import dev.dimension.flare.data.datasource.microblog.ListMetaDataType -import dev.dimension.flare.data.datasource.microblog.MemoryPagingSource import dev.dimension.flare.data.datasource.microblog.NotificationFilter import dev.dimension.flare.data.datasource.microblog.ProfileAction 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.memoryPager +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.pleroma.PleromaDataSource import dev.dimension.flare.data.network.mastodon.MastodonService -import dev.dimension.flare.data.network.mastodon.api.model.PostAccounts -import dev.dimension.flare.data.network.mastodon.api.model.PostList import dev.dimension.flare.data.network.mastodon.api.model.PostPoll import dev.dimension.flare.data.network.mastodon.api.model.PostReport import dev.dimension.flare.data.network.mastodon.api.model.PostStatus @@ -55,19 +50,16 @@ 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.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.UiUserV2 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.collections.immutable.toPersistentList @@ -235,7 +227,7 @@ internal open class MastodonDataSource( NotificationFilter.Mention, ) - override fun userByAcct(acct: String): CacheData { + override fun userByAcct(acct: String): CacheData { val (name, host) = MicroBlogKey.valueOf(acct) return Cacheable( fetchSource = { @@ -402,6 +394,7 @@ internal open class MastodonDataSource( 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 }, inReplyToID = inReplyToID, mediaIDS = mediaIds.takeIf { it.isNotEmpty() }, @@ -777,7 +770,7 @@ internal open class MastodonDataSource( } } - override fun discoverUsers(pageSize: Int): Flow> = + override fun discoverUsers(pageSize: Int): Flow> = Pager( config = pagingConfig, ) { @@ -815,7 +808,7 @@ internal open class MastodonDataSource( override fun searchUser( query: String, pageSize: Int, - ): Flow> = + ): Flow> = Pager( config = pagingConfig, ) { @@ -831,7 +824,7 @@ internal open class MastodonDataSource( query: String, scope: CoroutineScope, pageSize: Int = 20, - ): Flow> = + ): Flow> = Pager( config = pagingConfig, ) { @@ -915,256 +908,34 @@ internal open class MastodonDataSource( }, ) - private val listKey: String - get() = "allLists_$accountKey" - - override fun myList(scope: CoroutineScope): Flow> = - memoryPager( - pageSize = 20, - pagingKey = listKey, - scope = scope, - mediator = - object : BaseRemoteMediator() { - override suspend fun doLoad( - loadType: LoadType, - state: PagingState, - ): MediatorResult { - if (loadType == LoadType.PREPEND) { - return MediatorResult.Success(endOfPaginationReached = true) - } - val result = - service - .lists() - .mapNotNull { - it.id?.let { it1 -> - it.render() - } - }.toImmutableList() - - MemoryPagingSource.update( - key = listKey, - value = result, - ) - - return MediatorResult.Success( - endOfPaginationReached = true, - ) - } - }, + val listLoader: ListLoader by lazy { + MastodonListLoader( + service = service, + accountKey = accountKey, ) - - suspend fun createList(title: String) { - tryRun { - service.createList(PostList(title = title)) - }.onSuccess { response -> - if (response.id != null) { - MemoryPagingSource.updateWith( - key = listKey, - ) { - it - .plus( - UiList( - id = response.id, - title = title, - platformType = PlatformType.Mastodon, - ), - ).toImmutableList() - } - } - } - } - - override suspend fun deleteList(listId: String) { - tryRun { - service.deleteList(listId) - }.onSuccess { - MemoryPagingSource.updateWith( - key = listKey, - ) { - it - .filter { list -> list.id != listId } - .toImmutableList() - } - } - } - - private suspend fun updateList( - listId: String, - title: String, - ) { - tryRun { - service.updateList(listId, PostList(title = title)) - }.onSuccess { - MemoryPagingSource.updateWith( - key = listKey, - ) { - it - .map { list -> - if (list.id == listId) { - list.copy(title = title) - } else { - list - } - }.toImmutableList() - } - } } - override fun listInfo(listId: String): CacheData = - MemCacheable( - key = "listInfo_$listId", - fetchSource = { - service.getList(listId).render() - }, + val listMemberLoader: ListMemberLoader by lazy { + MastodonListMemberLoader( + service = service, + accountKey = accountKey, ) - - private fun listMemberKey(listId: String) = "listMembers_$listId" - - override fun listMembers( - listId: String, - scope: CoroutineScope, - pageSize: Int, - ): Flow> = - memoryPager( - pageSize = pageSize, - pagingKey = listMemberKey(listId), - scope = scope, - mediator = - object : BaseRemoteMediator() { - override suspend fun doLoad( - loadType: LoadType, - state: PagingState, - ): MediatorResult { - if (loadType == LoadType.PREPEND) { - return MediatorResult.Success(endOfPaginationReached = true) - } - val key = - if (loadType == LoadType.REFRESH) { - null - } else { - MemoryPagingSource - .get(key = listMemberKey(listId)) - ?.lastOrNull() - ?.key - ?.id - } - val result = - service - .listMembers(listId, limit = pageSize, max_id = key) - .map { - it.toDbUser(accountKey.host).render(accountKey) - } - - if (loadType == LoadType.REFRESH) { - MemoryPagingSource.update( - key = listMemberKey(listId), - value = result.toImmutableList(), - ) - } else if (loadType == LoadType.APPEND) { - MemoryPagingSource.append( - key = listMemberKey(listId), - value = result.toImmutableList(), - ) - } - - return MediatorResult.Success( - endOfPaginationReached = result.isEmpty(), - ) - } - }, - ) - - override suspend fun addMember( - listId: String, - userKey: MicroBlogKey, - ) { - tryRun { - service.addMember( - listId, - PostAccounts(listOf(userKey.id)), - ) - val user = - service - .lookupUser(userKey.id) - .toDbUser(accountKey.host) - .render(accountKey) - MemoryPagingSource.updateWith( - key = listMemberKey(listId), - ) { - (listOf(user) + it) - .distinctBy { - it.key - }.toImmutableList() - } - val list = service.getList(listId) - if (list.id != null) { - MemCacheable.updateWith>( - key = userListsKey(userKey), - ) { - it - .plus(list.render()) - .toImmutableList() - } - } - } } - override suspend fun removeMember( - listId: String, - userKey: MicroBlogKey, - ) { - tryRun { - service.removeMember( - listId, - PostAccounts(listOf(userKey.id)), - ) - MemoryPagingSource.updateWith( - key = listMemberKey(listId), - ) { - it - .filter { user -> user.key.id != userKey.id } - .toImmutableList() - } - MemCacheable.updateWith>( - key = userListsKey(userKey), - ) { - it - .filter { list -> list.id != listId } - .toImmutableList() - } - } - } - - override fun listMemberCache(listId: String): Flow> = - MemoryPagingSource.getFlow(listMemberKey(listId)) - - private fun userListsKey(userKey: MicroBlogKey) = "userLists_${userKey.id}" - - override fun userLists(userKey: MicroBlogKey): MemCacheable> = - MemCacheable( - key = userListsKey(userKey), - ) { - service - .accountLists(userKey.id) - .mapNotNull { - it.id?.let { _ -> - it.render() - } - }.toImmutableList() - } - - override val supportedMetaData: ImmutableList - get() = persistentListOf(ListMetaDataType.TITLE) - - override suspend fun createList(metaData: ListMetaData) { - createList(metaData.title) + override val listHandler: ListHandler by lazy { + ListHandler( + pagingKey = "lists_$accountKey", + accountKey = accountKey, + loader = listLoader, + ) } - override suspend fun updateList( - listId: String, - metaData: ListMetaData, - ) { - updateList(listId, metaData.title) + override val listMemberHandler: ListMemberHandler by lazy { + ListMemberHandler( + pagingKey = "list_members_$accountKey", + accountKey = accountKey, + loader = listMemberLoader, + ) } private val notificationMarkerKey: String @@ -1274,7 +1045,7 @@ internal open class MastodonDataSource( userKey: MicroBlogKey, scope: CoroutineScope, pageSize: Int, - ): Flow> = + ): Flow> = Pager( config = pagingConfig, ) { @@ -1290,7 +1061,7 @@ internal open class MastodonDataSource( userKey: MicroBlogKey, scope: CoroutineScope, pageSize: Int, - ): Flow> = + ): Flow> = Pager( config = pagingConfig, ) { 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 3fbb8a53a..063b486fa 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 @@ -4,7 +4,7 @@ import androidx.paging.PagingState import dev.dimension.flare.common.BasePagingSource import dev.dimension.flare.data.network.mastodon.api.AccountResources import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiUserV2 +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.mapper.render internal class MastodonFansPagingSource( @@ -12,10 +12,10 @@ internal class MastodonFansPagingSource( private val accountKey: MicroBlogKey?, private val host: String, private val userKey: MicroBlogKey, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null +) : BasePagingSource() { + override fun getRefreshKey(state: PagingState): String? = null - override suspend fun doLoad(params: LoadParams): LoadResult { + override suspend fun doLoad(params: LoadParams): LoadResult { val maxId = params.key val limit = params.loadSize val response = 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 e8e739669..8931fae45 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 @@ -4,7 +4,7 @@ import androidx.paging.PagingState import dev.dimension.flare.common.BasePagingSource import dev.dimension.flare.data.network.mastodon.api.AccountResources import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiUserV2 +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.mapper.render internal class MastodonFollowingPagingSource( @@ -12,10 +12,10 @@ internal class MastodonFollowingPagingSource( private val accountKey: MicroBlogKey?, private val host: String, private val userKey: MicroBlogKey, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null +) : BasePagingSource() { + override fun getRefreshKey(state: PagingState): String? = null - override suspend fun doLoad(params: LoadParams): LoadResult { + override suspend fun doLoad(params: LoadParams): LoadResult { val maxId = params.key val limit = params.loadSize val response = 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 new file mode 100644 index 000000000..8b4f180ea --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonListLoader.kt @@ -0,0 +1,67 @@ +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.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.MastodonList +import dev.dimension.flare.data.network.mastodon.api.model.PostList +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiList +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal class MastodonListLoader( + private val service: MastodonService, + private val accountKey: MicroBlogKey, +) : ListLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend) { + return PagingResult() + } + val lists = service.lists() + return PagingResult( + data = lists.mapNotNull { it.toUiList(accountKey) }, + ) + } + + override suspend fun info(listId: String): UiList = + service.getList(listId).toUiList(accountKey) + ?: error("Failed to parse list info") + + override suspend fun create(metaData: ListMetaData): UiList = + service + .createList(PostList(title = metaData.title)) + .toUiList(accountKey) + ?: error("Failed to parse created list") + + override suspend fun update( + listId: String, + metaData: ListMetaData, + ): UiList = + service + .updateList(listId, PostList(title = metaData.title)) + .toUiList(accountKey) + ?: error("Failed to parse updated list") + + override suspend fun delete(listId: String) { + service.deleteList(listId) + } + + override val supportedMetaData: ImmutableList + get() = persistentListOf(ListMetaDataType.TITLE) + + private fun MastodonList.toUiList(accountKey: MicroBlogKey): UiList.List? { + val id = id ?: return null + val title = title ?: return null + return UiList.List( + id = id, + title = title, + ) + } +} 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 new file mode 100644 index 000000000..5dda7a782 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonListMemberLoader.kt @@ -0,0 +1,97 @@ +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.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.MastodonList +import dev.dimension.flare.data.network.mastodon.api.model.PostAccounts +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiList + +internal class MastodonListMemberLoader( + private val service: MastodonService, + private val accountKey: MicroBlogKey, +) : ListMemberLoader { + override suspend fun loadMembers( + pageSize: Int, + request: PagingRequest, + listId: String, + ): PagingResult { + val maxId = + when (request) { + is PagingRequest.Append -> request.nextKey + is PagingRequest.Refresh -> null + is PagingRequest.Prepend -> return PagingResult() + } + + val response = + service.listMembers( + listId = listId, + limit = pageSize, + max_id = maxId, + ) + + val users = + response.map { + it.toDbUser(accountKey.host) + } + + return PagingResult( + data = users, + nextKey = response.next, + ) + } + + override suspend fun addMember( + listId: String, + userKey: MicroBlogKey, + ): DbUser { + service.addMember( + listId = listId, + accounts = PostAccounts(listOf(userKey.id)), + ) + return service + .lookupUser(userKey.id) + .toDbUser(accountKey.host) + } + + override suspend fun removeMember( + listId: String, + userKey: MicroBlogKey, + ) { + service.removeMember( + listId = listId, + accounts = PostAccounts(listOf(userKey.id)), + ) + } + + override suspend fun loadUserLists( + pageSize: Int, + request: PagingRequest, + userKey: MicroBlogKey, + ): PagingResult { + if (request is PagingRequest.Prepend) { + return PagingResult() + } + val response = service.accountLists(userKey.id) + val lists = + response.mapNotNull { + it.toUiList(accountKey) + } + return PagingResult( + data = lists, + ) + } + + private fun MastodonList.toUiList(accountKey: MicroBlogKey): UiList.List? { + val id = id ?: return null + val title = title ?: return null + return UiList.List( + id = id, + title = title, + ) + } +} 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 af8331c65..1d1b32614 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.mastodon import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -20,11 +23,11 @@ internal class MentionRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .notification( limit = pageSize, @@ -32,7 +35,7 @@ internal class MentionRemoteMediator( ) } - is Request.Prepend -> { + is PagingRequest.Prepend -> { service.notification( limit = pageSize, min_id = request.previousKey, @@ -40,7 +43,7 @@ internal class MentionRemoteMediator( ) } - is Request.Append -> { + is PagingRequest.Append -> { service.notification( limit = pageSize, max_id = request.nextKey, @@ -49,7 +52,7 @@ internal class MentionRemoteMediator( } } - return Result( + return PagingResult( endOfPaginationReached = response.isEmpty(), data = response.toDb( 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 00d934f1d..71a596eec 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.mastodon import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -22,11 +25,11 @@ internal class NotificationRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .notification( limit = pageSize, @@ -38,14 +41,14 @@ internal class NotificationRemoteMediator( } } - is Request.Prepend -> { + is PagingRequest.Prepend -> { service.notification( limit = pageSize, min_id = request.previousKey, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.notification( limit = pageSize, max_id = request.nextKey, @@ -53,7 +56,7 @@ internal class NotificationRemoteMediator( } } - return Result( + return PagingResult( endOfPaginationReached = response.isEmpty(), data = response.toDb( 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 1118a4906..a2856540a 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.mastodon import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.mastodon.MastodonService import dev.dimension.flare.model.MicroBlogKey @@ -27,11 +30,11 @@ internal class PublicTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .publicTimeline( limit = pageSize, @@ -39,13 +42,13 @@ internal class PublicTimelineRemoteMediator( ) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.publicTimeline( limit = pageSize, max_id = request.nextKey, @@ -54,7 +57,7 @@ internal class PublicTimelineRemoteMediator( } } - return Result( + return PagingResult( endOfPaginationReached = response.isEmpty(), data = response.toDbPagingTimeline( 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 767bb69c4..2ffdc03b5 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.mastodon import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.mastodon.MastodonService import dev.dimension.flare.model.MicroBlogKey @@ -25,17 +28,17 @@ internal class SearchStatusPagingSource( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - Request.Refresh -> { + PagingRequest.Refresh -> { if (query.startsWith("#")) { service.hashtagTimeline( hashtag = query.removePrefix("#"), @@ -51,7 +54,7 @@ internal class SearchStatusPagingSource( } } - is Request.Append -> { + is PagingRequest.Append -> { if (query.startsWith("#")) { service.hashtagTimeline( hashtag = query.removePrefix("#"), @@ -70,7 +73,7 @@ internal class SearchStatusPagingSource( } } ?: emptyList() - return Result( + return PagingResult( endOfPaginationReached = response.isEmpty(), data = response.toDbPagingTimeline( 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 5dcc2701d..50082516b 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 @@ -4,7 +4,7 @@ import androidx.paging.PagingState import dev.dimension.flare.common.BasePagingSource import dev.dimension.flare.data.network.mastodon.api.SearchResources import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiUserV2 +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.mapper.render internal class SearchUserPagingSource( @@ -14,10 +14,10 @@ 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 +) : BasePagingSource() { + override fun getRefreshKey(state: PagingState): String? = null - override suspend fun doLoad(params: LoadParams): LoadResult { + override suspend fun doLoad(params: LoadParams): LoadResult { service .searchV2( query = query, 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 34d1da636..bb6b09f3c 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,11 +1,14 @@ package dev.dimension.flare.data.datasource.mastodon import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -34,13 +37,13 @@ internal class StatusDetailRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val result = when (request) { - is Request.Append -> { + is PagingRequest.Append -> { if (statusOnly) { - return Result( + return PagingResult( endOfPaginationReached = true, ) } else { @@ -55,11 +58,11 @@ internal class StatusDetailRemoteMediator( context.ancestors.orEmpty() + listOf(current) + context.descendants.orEmpty() } } - is Request.Prepend -> - return Result( + is PagingRequest.Prepend -> + return PagingResult( endOfPaginationReached = true, ) - Request.Refresh -> { + PagingRequest.Refresh -> { val exists = database.pagingTimelineDao().existsPaging(accountKey, pagingKey) if (!exists) { val status = database.statusDao().get(statusKey, AccountType.Specific(accountKey)).firstOrNull() @@ -87,9 +90,9 @@ internal class StatusDetailRemoteMediator( listOf(current) } } - val shouldLoadMore = !(request is Request.Append || statusOnly) + val shouldLoadMore = !(request is PagingRequest.Append || statusOnly) - return Result( + return PagingResult( endOfPaginationReached = !shouldLoadMore, data = result.toDbPagingTimeline( 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 index 39bdf1424..1715e1dba 100644 --- 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 @@ -4,17 +4,17 @@ 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.UiUserV2 +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 +) : BasePagingSource() { + override fun getRefreshKey(state: PagingState): Int? = null - override suspend fun doLoad(params: LoadParams): LoadResult { + override suspend fun doLoad(params: LoadParams): LoadResult { service .suggestionsUsers() .mapNotNull { 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 e60a8b2a6..8e38aa7aa 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.mastodon import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.mastodon.MastodonService import dev.dimension.flare.model.MicroBlogKey @@ -37,11 +40,11 @@ internal class UserTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { val pinned = if (withPinned) { service.userTimeline( @@ -61,13 +64,13 @@ internal class UserTimelineRemoteMediator( ) + pinned } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.userTimeline( user_id = userKey.id, limit = pageSize, @@ -78,7 +81,7 @@ internal class UserTimelineRemoteMediator( } } - return Result( + return PagingResult( endOfPaginationReached = response.isEmpty(), data = response.toDbPagingTimeline( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ListDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ListDataSource.kt deleted file mode 100644 index def8645d3..000000000 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ListDataSource.kt +++ /dev/null @@ -1,64 +0,0 @@ -package dev.dimension.flare.data.datasource.microblog - -import androidx.paging.PagingData -import dev.dimension.flare.common.BaseTimelineLoader -import dev.dimension.flare.common.CacheData -import dev.dimension.flare.common.FileItem -import dev.dimension.flare.common.MemCacheable -import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiList -import dev.dimension.flare.ui.model.UiUserV2 -import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow - -internal interface ListDataSource { - fun myList(scope: CoroutineScope): Flow> - - fun listInfo(listId: String): CacheData - - fun listMembers( - listId: String, - scope: CoroutineScope, - pageSize: Int = 20, - ): Flow> - - suspend fun addMember( - listId: String, - userKey: MicroBlogKey, - ) - - suspend fun removeMember( - listId: String, - userKey: MicroBlogKey, - ) - - fun listTimeline(listId: String): BaseTimelineLoader - - suspend fun deleteList(listId: String) - - val supportedMetaData: ImmutableList - - suspend fun updateList( - listId: String, - metaData: ListMetaData, - ) - - suspend fun createList(metaData: ListMetaData) - - fun listMemberCache(listId: String): Flow> - - fun userLists(userKey: MicroBlogKey): MemCacheable> -} - -public data class ListMetaData( - val title: String, - val description: String? = null, - val avatar: FileItem? = null, -) - -public enum class ListMetaDataType { - TITLE, - DESCRIPTION, - AVATAR, -} 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 42fd3f531..dbd5e612c 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,13 +1,12 @@ package dev.dimension.flare.data.datasource.microblog import androidx.paging.PagingData -import dev.dimension.flare.common.BaseTimelineLoader import dev.dimension.flare.common.CacheData +import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineLoader 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.UiUserV2 import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow @@ -15,7 +14,7 @@ import kotlinx.coroutines.flow.Flow internal interface MicroblogDataSource { fun homeTimeline(): BaseTimelineLoader - fun userByAcct(acct: String): CacheData + fun userByAcct(acct: String): CacheData fun userById(id: String): CacheData @@ -33,9 +32,9 @@ internal interface MicroblogDataSource { fun searchUser( query: String, pageSize: Int = 20, - ): Flow> + ): Flow> - fun discoverUsers(pageSize: Int = 20): Flow> + fun discoverUsers(pageSize: Int = 20): Flow> fun discoverStatuses(): BaseTimelineLoader @@ -45,13 +44,13 @@ internal interface MicroblogDataSource { userKey: MicroBlogKey, scope: CoroutineScope, pageSize: Int = 20, - ): Flow> + ): Flow> fun fans( userKey: MicroBlogKey, scope: CoroutineScope, pageSize: Int = 20, - ): Flow> + ): Flow> 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 22f0a3539..d6b1ddecb 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 @@ -2,9 +2,12 @@ package dev.dimension.flare.data.datasource.microblog import SnowflakeIdGenerator import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -26,13 +29,13 @@ internal class MixedRemoteMediator( @OptIn(ExperimentalPagingApi::class) override suspend fun timeline( pageSize: Int, - request: Request, - ): Result = + request: PagingRequest, + ): PagingResult = coroutineScope { - if (request is Request.Prepend) { - Result(endOfPaginationReached = true) + if (request is PagingRequest.Prepend) { + PagingResult(endOfPaginationReached = true) } else { - if (request is Request.Refresh) { + if (request is PagingRequest.Refresh) { currentMediators = mediators } val response = @@ -44,7 +47,7 @@ internal class MixedRemoteMediator( runCatching { subRequest.load(pageSize) }.getOrElse { - Result(endOfPaginationReached = true) + PagingResult(endOfPaginationReached = true) }.let { SubResponse(subRequest.mediator, it) } @@ -77,14 +80,14 @@ internal class MixedRemoteMediator( currentMediators = response.mapNotNull { - if (it.result.endOfPaginationReached) { + if (it.result.nextKey == null) { null } else { it.mediator } } - Result( + PagingResult( endOfPaginationReached = currentMediators.isEmpty(), data = mixedTimelineResult + timelineResult, nextKey = if (currentMediators.isEmpty()) null else "mixed_next_key", @@ -94,46 +97,46 @@ internal class MixedRemoteMediator( } private suspend fun getSubRequest( - request: Request, + request: PagingRequest, mediator: BaseTimelineRemoteMediator, ): SubRequest? = when (request) { - is Request.Append -> { + is PagingRequest.Append -> { database .pagingTimelineDao() .getPagingKey(mediator.pagingKey) ?.nextKey - ?.let(Request::Append) + ?.let(PagingRequest::Append) } - is Request.Prepend -> + is PagingRequest.Prepend -> database .pagingTimelineDao() .getPagingKey(mediator.pagingKey) ?.prevKey - ?.let(Request::Prepend) + ?.let(PagingRequest::Prepend) - is Request.Refresh -> Request.Refresh + is PagingRequest.Refresh -> PagingRequest.Refresh }?.let { SubRequest(mediator, it) } private suspend fun saveSubResponse( - request: Request, + request: PagingRequest, subResponse: SubResponse, ) { val (mediator, result) = subResponse - if (request is Request.Prepend && result.previousKey != null) { + if (request is PagingRequest.Prepend && result.previousKey != null) { database.pagingTimelineDao().updatePagingKeyPrevKey( pagingKey = mediator.pagingKey, prevKey = result.previousKey, ) - } else if (request is Request.Append && result.nextKey != null) { + } else if (request is PagingRequest.Append && result.nextKey != null) { database.pagingTimelineDao().updatePagingKeyNextKey( pagingKey = mediator.pagingKey, nextKey = result.nextKey, ) - } else if (request is Request.Refresh) { + } else if (request is PagingRequest.Refresh) { database.pagingTimelineDao().deletePagingKey(mediator.pagingKey) database.pagingTimelineDao().insertPagingKey( dev.dimension.flare.data.database.cache.model.DbPagingKey( @@ -147,13 +150,13 @@ internal class MixedRemoteMediator( private data class SubRequest( val mediator: BaseTimelineRemoteMediator, - val request: Request, + val request: PagingRequest, ) { suspend fun load(pageSize: Int) = mediator.timeline(pageSize, request) } private data class SubResponse( val mediator: BaseTimelineRemoteMediator, - val result: Result, + val result: PagingResult, ) } 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 1f8dadb9f..46bd26409 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 @@ -10,8 +10,8 @@ import androidx.paging.filter import androidx.paging.map import dev.dimension.flare.common.BasePagingSource import dev.dimension.flare.common.BaseRemoteMediator -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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 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 435804598..81b5475c4 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,7 +1,7 @@ package dev.dimension.flare.data.datasource.microblog import androidx.compose.runtime.Immutable -import dev.dimension.flare.common.BaseTimelineLoader +import dev.dimension.flare.data.datasource.microblog.paging.BaseTimelineLoader @Immutable public sealed interface ProfileTab { 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 new file mode 100644 index 000000000..c7b454834 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListDataSource.kt @@ -0,0 +1,10 @@ +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/list/ListHandler.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListHandler.kt new file mode 100644 index 000000000..40c110017 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListHandler.kt @@ -0,0 +1,223 @@ +package dev.dimension.flare.data.datasource.microblog.list + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.Pager +import androidx.paging.map +import dev.dimension.flare.common.CacheData +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.model.DbList +import dev.dimension.flare.data.database.cache.model.DbListPaging +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 +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.UiList +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +@OptIn(ExperimentalPagingApi::class) +internal class ListHandler( + private val pagingKey: String, + private val accountKey: MicroBlogKey, + private val loader: ListLoader, +) : KoinComponent { + private val accountType: DbAccountType = AccountType.Specific(accountKey) + private val database: CacheDatabase by inject() + + val supportedMetaData: ImmutableList by lazy { + loader.supportedMetaData + } + val data by lazy { + Pager( + config = pagingConfig, + remoteMediator = + createPagingRemoteMediator( + pagingKey = pagingKey, + database = database, + onLoad = { pageSize, request -> + loader.load(pageSize, request) + }, + onSave = { request, data -> + if (request == PagingRequest.Refresh) { + database.listDao().deleteByPagingKey(pagingKey) + } + database.listDao().insertAllList( + data.map { item -> + DbList( + listKey = MicroBlogKey(item.id, accountKey.host), + accountType = accountType, + content = DbList.ListContent(item), + ) + }, + ) + + database.listDao().insertAllPaging( + data.map { item -> + DbListPaging( + accountType = accountType, + pagingKey = pagingKey, + listKey = MicroBlogKey(item.id, accountKey.host), + ) + }, + ) + }, + ), + pagingSourceFactory = { + database.listDao().getPagingSource( + pagingKey = pagingKey, + ) + }, + ).flow.map { + it.map { + it.list.content.data + } + } + } + + val cacheData by lazy { + database.listDao().getListKeysFlow(pagingKey).map { + it.map { + it.list.content.data + } + } + } + + fun listInfo(listId: String): CacheData { + val listKey = MicroBlogKey(listId, accountKey.host) + return Cacheable( + fetchSource = { + val info = loader.info(listId) + database.connect { + database.listDao().insertAllList( + listOf( + DbList( + listKey = MicroBlogKey(info.id, accountKey.host), + accountType = accountType, + content = DbList.ListContent(info), + ), + ), + ) + } + }, + cacheSource = { + database + .listDao() + .getList( + listKey = listKey, + accountType = accountType, + ).mapNotNull { dbList -> + dbList?.content?.data + } + }, + ) + } + + suspend fun create(metaData: ListMetaData) { + tryRun { + loader.create(metaData) + }.onSuccess { result -> + database.connect { + database.listDao().insertAllList( + listOf( + DbList( + listKey = MicroBlogKey(result.id, accountKey.host), + accountType = accountType, + content = DbList.ListContent(result), + ), + ), + ) + database.listDao().insertAllPaging( + listOf( + DbListPaging( + accountType = accountType, + pagingKey = pagingKey, + listKey = MicroBlogKey(result.id, accountKey.host), + ), + ), + ) + } + } + } + + suspend fun update( + listId: String, + metaData: ListMetaData, + ) { + val listKey = MicroBlogKey(listId, accountKey.host) + tryRun { + loader.update(listId, metaData) + }.onSuccess { result -> + database.connect { + database.listDao().updateListContent( + listKey = listKey, + accountType = accountType, + content = DbList.ListContent(result), + ) + } + } + } + + suspend fun delete(listId: String) { + val listKey = MicroBlogKey(listId, accountKey.host) + tryRun { + loader.delete(listId) + }.onSuccess { + database.connect { + database.listDao().deleteByListKey( + listKey = listKey, + accountType = accountType, + ) + database.listDao().deletePagingByListKey( + listKey = listKey, + accountType = accountType, + ) + } + } + } + + suspend fun insertToDatabase(data: UiList) { + val listKey = MicroBlogKey(data.id, accountKey.host) + database.connect { + database.listDao().insertAllList( + listOf( + DbList( + listKey = listKey, + accountType = AccountType.Specific(accountKey), + content = DbList.ListContent(data), + ), + ), + ) + + database.listDao().insertAllPaging( + listOf( + DbListPaging( + accountType = AccountType.Specific(accountKey), + pagingKey = pagingKey, + listKey = listKey, + ), + ), + ) + } + } + + suspend fun withDatabase(block: suspend (update: suspend (UiList) -> Unit) -> Unit) { + block.invoke { data -> + val listKey = MicroBlogKey(data.id, accountKey.host) + database.connect { + database.listDao().updateListContent( + listKey = listKey, + accountType = AccountType.Specific(accountKey), + content = DbList.ListContent(data), + ) + } + } + } +} 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/list/ListLoader.kt new file mode 100644 index 000000000..5e3cabfd2 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListLoader.kt @@ -0,0 +1,26 @@ +package dev.dimension.flare.data.datasource.microblog.list + +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 +import kotlinx.collections.immutable.ImmutableList + +internal interface ListLoader { + suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult + + suspend fun info(listId: String): UiList + + suspend fun create(metaData: ListMetaData): UiList + + suspend fun update( + listId: String, + metaData: ListMetaData, + ): UiList + + suspend fun delete(listId: String) + + val supportedMetaData: ImmutableList +} 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/list/ListMemberHandler.kt new file mode 100644 index 000000000..342068e42 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListMemberHandler.kt @@ -0,0 +1,173 @@ +package dev.dimension.flare.data.datasource.microblog.list + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.Pager +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.model.DbList +import dev.dimension.flare.data.database.cache.model.DbListMember +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 +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 + +@OptIn(ExperimentalPagingApi::class) +internal class ListMemberHandler( + private val pagingKey: String, + private val accountKey: MicroBlogKey, + private val loader: ListMemberLoader, +) : KoinComponent { + private val accountType: DbAccountType = AccountType.Specific(accountKey) + private val database: CacheDatabase by inject() + private val memberPagingKey: String + get() = "${pagingKey}_members" + + fun listMembers(listId: String) = + Pager( + config = pagingConfig, + remoteMediator = + createPagingRemoteMediator( + pagingKey = "${memberPagingKey}_$listId", + database = database, + onLoad = { pageSize, request -> + loader.loadMembers( + pageSize = pageSize, + request = request, + listId = listId, + ) + }, + onSave = { request, data -> + val listKey = MicroBlogKey(listId, accountKey.host) + if (request == PagingRequest.Refresh) { + database.listDao().deleteMembersByListKey(listKey) + } + database.listDao().insertAllMember( + data.map { item -> + DbListMember( + listKey = listKey, + memberKey = item.userKey, + ) + }, + ) + database.userDao().insertAll(data) + }, + ), + pagingSourceFactory = { + database.listDao().getListMembers( + listKey = MicroBlogKey(listId, accountKey.host), + ) + }, + ).flow.map { + it.map { + it.user.render(accountKey) + } + } + + fun listMembersListFlow(listId: String) = + database + .listDao() + .getListMembersFlow( + listKey = MicroBlogKey(listId, accountKey.host), + ).map { members -> + members.map { member -> + member.user.render(accountKey) + } + } + + suspend fun addMember( + listId: String, + userKey: MicroBlogKey, + ) { + val listKey = MicroBlogKey(listId, accountKey.host) + tryRun { + loader.addMember(listId, userKey) + }.onSuccess { user -> + database.connect { + database.listDao().insertAllMember( + listOf( + DbListMember( + listKey = listKey, + memberKey = userKey, + ), + ), + ) + database.userDao().insertAll( + listOf(user), + ) + } + } + } + + suspend fun removeMember( + listId: String, + userKey: MicroBlogKey, + ) { + val listKey = MicroBlogKey(listId, accountKey.host) + tryRun { + loader.removeMember(listId, userKey) + }.onSuccess { + database.connect { + database.listDao().deleteMemberFromList( + listKey = listKey, + memberKey = userKey, + ) + } + } + } + + private val userListsPagingKey: String + get() = "${pagingKey}_user_lists" + + fun userLists(userKey: MicroBlogKey) = + Cacheable( + fetchSource = { + tryRun { + val result = + loader.loadUserLists( + pageSize = 100, + request = PagingRequest.Refresh, + userKey = userKey, + ) + val data = result.data + database.connect { + database.listDao().insertAllList( + data.map { item -> + DbList( + listKey = MicroBlogKey(item.id, accountKey.host), + accountType = accountType, + content = DbList.ListContent(item), + ) + }, + ) + database.listDao().insertAllMember( + data.map { item -> + DbListMember( + listKey = MicroBlogKey(item.id, accountKey.host), + memberKey = userKey, + ) + }, + ) + } + } + }, + cacheSource = { + database + .listDao() + .getUserByKeyFlow(userKey) + .map { + it.listMemberships.map { + it.list.content.data + } + } + }, + ) +} 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/list/ListMemberLoader.kt new file mode 100644 index 000000000..1dee97911 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListMemberLoader.kt @@ -0,0 +1,31 @@ +package dev.dimension.flare.data.datasource.microblog.list + +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 + +internal interface ListMemberLoader { + suspend fun loadMembers( + pageSize: Int, + request: PagingRequest, + listId: String, + ): PagingResult + + suspend fun addMember( + listId: String, + userKey: MicroBlogKey, + ): DbUser + + suspend fun removeMember( + listId: String, + userKey: MicroBlogKey, + ) + + suspend fun loadUserLists( + pageSize: Int, + request: PagingRequest, + userKey: MicroBlogKey, + ): PagingResult +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListMetaData.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListMetaData.kt new file mode 100644 index 000000000..ea22cc3ad --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListMetaData.kt @@ -0,0 +1,9 @@ +package dev.dimension.flare.data.datasource.microblog.list + +import dev.dimension.flare.common.FileItem + +public data class ListMetaData( + val title: String, + val description: String? = null, + val avatar: FileItem? = null, +) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListMetaDataType.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListMetaDataType.kt new file mode 100644 index 000000000..1c7d85142 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/list/ListMetaDataType.kt @@ -0,0 +1,7 @@ +package dev.dimension.flare.data.datasource.microblog.list + +public enum class ListMetaDataType { + TITLE, + DESCRIPTION, + AVATAR, +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/BasePagingRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/BasePagingRemoteMediator.kt new file mode 100644 index 000000000..513eac7e1 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/BasePagingRemoteMediator.kt @@ -0,0 +1,107 @@ +package dev.dimension.flare.data.datasource.microblog.paging + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingState +import dev.dimension.flare.common.BaseRemoteMediator +import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.connect +import dev.dimension.flare.data.database.cache.model.DbPagingKey + +internal fun createPagingRemoteMediator( + database: CacheDatabase, + pagingKey: String, + onLoad: suspend (pageSize: Int, request: PagingRequest) -> PagingResult, + onSave: suspend (request: PagingRequest, data: List) -> Unit, +): BasePagingRemoteMediator = + object : BasePagingRemoteMediator(database) { + override val pagingKey: String = pagingKey + + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult = onLoad(pageSize, request) + + override suspend fun onSaveCache( + request: PagingRequest, + data: List, + ) { + onSave(request, data) + } + } + +internal abstract class BasePagingRemoteMediator( + private val database: CacheDatabase, +) : BaseRemoteMediator() { + abstract val pagingKey: String + + @OptIn(ExperimentalPagingApi::class) + override suspend fun doLoad( + loadType: LoadType, + state: PagingState, + ): MediatorResult { + val request: PagingRequest = + when (loadType) { + LoadType.REFRESH -> PagingRequest.Refresh + LoadType.PREPEND -> { + val previousKey = + database.pagingTimelineDao().getPagingKey(pagingKey)?.prevKey + ?: return MediatorResult.Success(endOfPaginationReached = true) + PagingRequest.Prepend(previousKey) + } + LoadType.APPEND -> { + val nextKey = + database.pagingTimelineDao().getPagingKey(pagingKey)?.nextKey + ?: return MediatorResult.Success(endOfPaginationReached = true) + PagingRequest.Append(nextKey) + } + } + + val result = + load( + pageSize = state.config.pageSize, + request = request, + ) + database.connect { + if (loadType == LoadType.REFRESH) { + database.pagingTimelineDao().deletePagingKey(pagingKey) + database.pagingTimelineDao().insertPagingKey( + DbPagingKey( + pagingKey = pagingKey, + nextKey = result.nextKey, + prevKey = result.previousKey, + ), + ) + } else if (loadType == LoadType.PREPEND && result.previousKey != null) { + database.pagingTimelineDao().updatePagingKeyPrevKey( + pagingKey = pagingKey, + prevKey = result.previousKey, + ) + } else if (loadType == LoadType.APPEND && result.nextKey != null) { + database.pagingTimelineDao().updatePagingKeyNextKey( + pagingKey = pagingKey, + nextKey = result.nextKey, + ) + } + onSaveCache(request, result.data) + } + return MediatorResult.Success( + endOfPaginationReached = + when (loadType) { + LoadType.REFRESH -> false + LoadType.PREPEND -> result.previousKey == null + LoadType.APPEND -> result.nextKey == null + }, + ) + } + + protected abstract suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult + + protected abstract suspend fun onSaveCache( + request: PagingRequest, + data: List, + ) +} 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 new file mode 100644 index 000000000..74cc588b9 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/BaseTimelineRemoteMediator.kt @@ -0,0 +1,52 @@ +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/PagingRequest.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/PagingRequest.kt new file mode 100644 index 000000000..3c855bd16 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/PagingRequest.kt @@ -0,0 +1,13 @@ +package dev.dimension.flare.data.datasource.microblog.paging + +internal sealed interface PagingRequest { + data object Refresh : PagingRequest + + data class Prepend( + val previousKey: String, + ) : PagingRequest + + data class Append( + val nextKey: String, + ) : PagingRequest +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/PagingResult.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/PagingResult.kt new file mode 100644 index 000000000..e8db47dea --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/PagingResult.kt @@ -0,0 +1,18 @@ +package dev.dimension.flare.data.datasource.microblog.paging + +internal data class PagingResult( + val data: List = emptyList(), + val nextKey: String? = null, + val previousKey: String? = null, +) { + constructor( + endOfPaginationReached: Boolean, + data: List = emptyList(), + nextKey: String? = null, + previousKey: String? = null, + ) : this( + data = data.toList(), + nextKey = if (endOfPaginationReached) null else nextKey, + previousKey = previousKey, + ) +} 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 651b3ab2b..c0dfadbbb 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -21,11 +24,11 @@ internal class AntennasTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .antennasNotes( AntennasNotesRequest( @@ -35,13 +38,13 @@ internal class AntennasTimelineRemoteMediator( ) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service .antennasNotes( AntennasNotesRequest( @@ -53,7 +56,7 @@ internal class AntennasTimelineRemoteMediator( } } - return Result( + return PagingResult( endOfPaginationReached = response.isEmpty(), data = response.toDbPagingTimeline( 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 new file mode 100644 index 000000000..dd103d3d4 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/ChannelTimelineRemoteMediator.kt @@ -0,0 +1,69 @@ +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.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 + +internal class ChannelTimelineRemoteMediator( + private val service: MisskeyService, + database: CacheDatabase, + private val accountKey: MicroBlogKey, + private val id: String, +) : BaseTimelineRemoteMediator( + database = database, + ) { + override val pagingKey = "channel_${id}_$accountKey" + + override suspend fun timeline( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + val response = + when (request) { + PagingRequest.Refresh -> { + service + .channelsTimeline( + channelsTimelineRequest = + ChannelsTimelineRequest( + channelId = id, + limit = pageSize, + ), + ) + } + + is PagingRequest.Prepend -> { + return PagingResult( + endOfPaginationReached = true, + ) + } + + is PagingRequest.Append -> { + service + .channelsTimeline( + channelsTimelineRequest = + ChannelsTimelineRequest( + channelId = id, + limit = pageSize, + untilId = request.nextKey, + ), + ) + } + } + + return PagingResult( + endOfPaginationReached = response.isEmpty(), + data = + response.toDbPagingTimeline( + accountKey = accountKey, + pagingKey = pagingKey, + ), + 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 27f05e13d..c7002f0d8 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -20,15 +23,15 @@ internal class DiscoverStatusRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service.notesFeatured(NotesFeaturedRequest(limit = pageSize)) } - is Request.Append -> { + is PagingRequest.Append -> { service.notesFeatured( NotesFeaturedRequest( limit = pageSize, @@ -38,13 +41,13 @@ internal class DiscoverStatusRemoteMediator( } else -> { - return Result( + return PagingResult( endOfPaginationReached = true, ) } } - return Result( + return PagingResult( endOfPaginationReached = true, data = response.toDbPagingTimeline( 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 456a42ec8..cca6dfacd 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 @@ -5,17 +5,17 @@ import dev.dimension.flare.common.BasePagingSource import dev.dimension.flare.data.network.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.UsersFollowersRequest import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiUserV2 +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.mapper.render internal class FansPagingSource( private val service: MisskeyService, private val accountKey: MicroBlogKey, private val userKey: MicroBlogKey, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null +) : BasePagingSource() { + override fun getRefreshKey(state: PagingState): String? = null - override suspend fun doLoad(params: LoadParams): LoadResult { + override suspend fun doLoad(params: LoadParams): LoadResult { val maxId = params.key val limit = params.loadSize val response = 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 f1401662b..4b835565a 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -26,15 +29,15 @@ internal class FavouriteTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - is Request.Prepend -> return Result( + is PagingRequest.Prepend -> return PagingResult( endOfPaginationReached = true, ) - Request.Refresh -> { + PagingRequest.Refresh -> { service.iFavorites( AdminAdListRequest( limit = pageSize, @@ -42,7 +45,7 @@ internal class FavouriteTimelineRemoteMediator( ) } - is Request.Append -> { + is PagingRequest.Append -> { service.iFavorites( AdminAdListRequest( limit = pageSize, @@ -64,7 +67,7 @@ internal class FavouriteTimelineRemoteMediator( }, ) - return Result( + return PagingResult( endOfPaginationReached = response.isEmpty(), data = data, nextKey = response.lastOrNull()?.noteId, 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 82ff87f24..cd7c15e0c 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 @@ -5,17 +5,17 @@ import dev.dimension.flare.common.BasePagingSource import dev.dimension.flare.data.network.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.UsersFollowersRequest import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiUserV2 +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.mapper.render internal class FollowingPagingSource( private val service: MisskeyService, private val accountKey: MicroBlogKey, private val userKey: MicroBlogKey, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null +) : BasePagingSource() { + override fun getRefreshKey(state: PagingState): String? = null - override suspend fun doLoad(params: LoadParams): LoadResult { + override suspend fun doLoad(params: LoadParams): LoadResult { val maxId = params.key val limit = params.loadSize val response = 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 20f1f57d4..7f27bfdda 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -22,15 +25,15 @@ internal class HomeTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - is Request.Prepend -> return Result( + is PagingRequest.Prepend -> return PagingResult( endOfPaginationReached = true, ) - Request.Refresh -> { + PagingRequest.Refresh -> { service.notesTimeline( NotesHybridTimelineRequest( limit = pageSize, @@ -38,7 +41,7 @@ internal class HomeTimelineRemoteMediator( ) } - is Request.Append -> { + is PagingRequest.Append -> { service.notesTimeline( NotesHybridTimelineRequest( limit = pageSize, @@ -48,7 +51,7 @@ internal class HomeTimelineRemoteMediator( } } - return Result( + return PagingResult( endOfPaginationReached = response.isEmpty(), data = response.toDbPagingTimeline( 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 86e654165..5cd2f29e9 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,8 +1,11 @@ package dev.dimension.flare.data.datasource.misskey -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -18,14 +21,14 @@ internal class HybridTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - is Request.Prepend -> return Result( + is PagingRequest.Prepend -> return PagingResult( endOfPaginationReached = true, ) - Request.Refresh -> { + PagingRequest.Refresh -> { service.notesHybridTimeline( NotesHybridTimelineRequest( limit = pageSize, @@ -33,7 +36,7 @@ internal class HybridTimelineRemoteMediator( ) } - is Request.Append -> { + is PagingRequest.Append -> { service.notesHybridTimeline( NotesHybridTimelineRequest( limit = pageSize, @@ -43,7 +46,7 @@ internal class HybridTimelineRemoteMediator( } } - return Result( + return PagingResult( endOfPaginationReached = response.isEmpty(), data = response.toDbPagingTimeline( 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 87eb512bb..f06b9a4a4 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -21,11 +24,11 @@ internal class ListTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .notesUserListTimeline( NotesUserListTimelineRequest( @@ -37,13 +40,13 @@ internal class ListTimelineRemoteMediator( ) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service .notesUserListTimeline( NotesUserListTimelineRequest( @@ -57,7 +60,7 @@ internal class ListTimelineRemoteMediator( } } - return Result( + return PagingResult( endOfPaginationReached = response.isEmpty(), data = response.toDbPagingTimeline( 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 b5b6fdc34..9ba41549c 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -20,14 +23,14 @@ internal class LocalTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - is Request.Prepend -> return Result( + is PagingRequest.Prepend -> return PagingResult( endOfPaginationReached = true, ) - Request.Refresh -> { + PagingRequest.Refresh -> { service.notesLocalTimeline( NotesLocalTimelineRequest( limit = pageSize, @@ -35,7 +38,7 @@ internal class LocalTimelineRemoteMediator( ) } - is Request.Append -> { + is PagingRequest.Append -> { service.notesLocalTimeline( NotesLocalTimelineRequest( limit = pageSize, @@ -45,7 +48,7 @@ internal class LocalTimelineRemoteMediator( } } - return Result( + return PagingResult( endOfPaginationReached = response.isEmpty(), data = response.toDbPagingTimeline( 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 d91ca4941..596559a4f 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -20,14 +23,14 @@ internal class MentionTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - is Request.Prepend -> return Result( + is PagingRequest.Prepend -> return PagingResult( endOfPaginationReached = true, ) - Request.Refresh -> { + PagingRequest.Refresh -> { service.notesMentions( NotesMentionsRequest( limit = pageSize, @@ -35,7 +38,7 @@ internal class MentionTimelineRemoteMediator( ) } - is Request.Append -> { + is PagingRequest.Append -> { service.notesMentions( NotesMentionsRequest( limit = pageSize, @@ -45,7 +48,7 @@ internal class MentionTimelineRemoteMediator( } } - return Result( + return PagingResult( endOfPaginationReached = response.isEmpty(), data = response.toDbPagingTimeline( 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 new file mode 100644 index 000000000..8b414bb61 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyChannelLoader.kt @@ -0,0 +1,114 @@ +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.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.ChannelsCreateRequest +import dev.dimension.flare.data.network.misskey.api.model.ChannelsFollowRequest +import dev.dimension.flare.data.network.misskey.api.model.ChannelsFollowedRequest +import dev.dimension.flare.data.network.misskey.api.model.ChannelsUpdateRequest +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiList +import dev.dimension.flare.ui.model.mapper.render +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +internal class MisskeyChannelLoader( + private val service: MisskeyService, + private val accountKey: MicroBlogKey, + private val source: Source, +) : ListLoader { + enum class Source { + Followed, + MyFavorites, + Owned, + } + + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend) { + return PagingResult() + } + val untilId = + when (request) { + is PagingRequest.Append -> request.nextKey + else -> null + } + + val result = + when (source) { + Source.Followed -> + service.channelsFollowed( + ChannelsFollowedRequest( + untilId = untilId, + limit = pageSize, + ), + ) + + Source.MyFavorites -> + service.channelsMyFavorites( + ChannelsFollowedRequest( + untilId = untilId, + limit = pageSize, + ), + ) + + Source.Owned -> + service.channelsOwned( + ChannelsFollowedRequest( + untilId = untilId, + limit = pageSize, + ), + ) + }.map { + it.render(accountKey) + }.toImmutableList() + + return PagingResult( + data = result, + nextKey = result.lastOrNull()?.id, + endOfPaginationReached = result.size < pageSize, + ) + } + + override suspend fun info(listId: String): UiList = + service + .channelsShow( + ChannelsFollowRequest( + channelId = listId, + ), + ).render(accountKey) + + override suspend fun create(metaData: ListMetaData): UiList = + service + .channelsCreate( + ChannelsCreateRequest( + name = metaData.title, + description = metaData.description, + ), + ).render(accountKey) + + override suspend fun update( + listId: String, + metaData: ListMetaData, + ): UiList = + service + .channelsUpdate( + ChannelsUpdateRequest( + channelId = listId, + name = metaData.title, + description = metaData.description, + ), + ).render(accountKey) + + override suspend fun delete(listId: String): Unit = throw UnsupportedOperationException("Delete channel is not supported") + + override val supportedMetaData: ImmutableList + get() = persistentListOf(ListMetaDataType.TITLE, ListMetaDataType.DESCRIPTION) +} 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 e8633cd22..063f884cf 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 @@ -1,12 +1,12 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi -import androidx.paging.LoadType import androidx.paging.Pager import androidx.paging.PagingData import androidx.paging.PagingState import androidx.paging.cachedIn -import dev.dimension.flare.common.BaseRemoteMediator +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 @@ -23,34 +23,29 @@ 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.ListDataSource -import dev.dimension.flare.data.datasource.microblog.ListMetaData -import dev.dimension.flare.data.datasource.microblog.ListMetaDataType -import dev.dimension.flare.data.datasource.microblog.MemoryPagingSource import dev.dimension.flare.data.datasource.microblog.NotificationFilter import dev.dimension.flare.data.datasource.microblog.ProfileAction 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.memoryPager +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.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.UsersListsCreateRequest -import dev.dimension.flare.data.network.misskey.api.model.UsersListsDeleteRequest -import dev.dimension.flare.data.network.misskey.api.model.UsersListsListRequest -import dev.dimension.flare.data.network.misskey.api.model.UsersListsMembershipRequest -import dev.dimension.flare.data.network.misskey.api.model.UsersListsPullRequest -import dev.dimension.flare.data.network.misskey.api.model.UsersListsShowRequest -import dev.dimension.flare.data.network.misskey.api.model.UsersListsUpdateRequest import dev.dimension.flare.data.network.misskey.api.model.UsersShowRequest import dev.dimension.flare.data.repository.AccountRepository import dev.dimension.flare.data.repository.LocalFilterRepository @@ -67,19 +62,18 @@ 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.UiUserV2 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.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 @@ -106,6 +100,7 @@ internal class MisskeyDataSource( private val service by lazy { dev.dimension.flare.data.network.misskey.MisskeyService( baseUrl = "https://$host/api/", + accountKey = accountKey, accessTokenFlow = accountRepository .credentialFlow(accountKey) @@ -167,6 +162,51 @@ internal class MisskeyDataSource( 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, + ), + ) + return LoadResult.Page( + data = + result.map { + it.render(accountKey) + }, + prevKey = null, + nextKey = null, + ) + } + } + }.flow + .cachedIn(scope) + .let { channels -> + combine( + channels, + channelHandler.cacheData, + ) { featured, followed -> + featured.map { item -> + if (item is UiList.Channel) { + item.copy( + isFollowing = followed.any { it.id == item.id }, + ) + } else { + item + } + } + } + }.cachedIn(scope) + override fun notification( type: NotificationFilter, pageSize: Int, @@ -205,7 +245,7 @@ internal class MisskeyDataSource( NotificationFilter.Mention, ) - override fun userByAcct(acct: String): CacheData { + override fun userByAcct(acct: String): CacheData { val (name, host) = MicroBlogKey.valueOf(acct) return Cacheable( fetchSource = { @@ -393,6 +433,7 @@ internal class MisskeyDataSource( 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" }, renoteId = renoteId, replyId = inReplyToID, @@ -731,7 +772,7 @@ internal class MisskeyDataSource( override fun searchUser( query: String, pageSize: Int, - ): Flow> = + ): Flow> = Pager( config = pagingConfig, ) { @@ -742,7 +783,7 @@ internal class MisskeyDataSource( ) }.flow - override fun discoverUsers(pageSize: Int): Flow> = + override fun discoverUsers(pageSize: Int): Flow> = Pager( config = pagingConfig, ) { @@ -943,7 +984,7 @@ internal class MisskeyDataSource( userKey: MicroBlogKey, scope: CoroutineScope, pageSize: Int, - ): Flow> = + ): Flow> = Pager( config = pagingConfig, ) { @@ -958,7 +999,7 @@ internal class MisskeyDataSource( userKey: MicroBlogKey, scope: CoroutineScope, pageSize: Int, - ): Flow> = + ): Flow> = Pager( config = pagingConfig, ) { @@ -1019,285 +1060,118 @@ internal class MisskeyDataSource( private val listKey: String get() = "allLists_$accountKey" - override fun myList(scope: CoroutineScope): Flow> = - memoryPager( - pageSize = 20, - pagingKey = listKey, - scope = scope, - mediator = - object : BaseRemoteMediator() { - override suspend fun doLoad( - loadType: LoadType, - state: PagingState, - ): MediatorResult { - if (loadType == LoadType.PREPEND) { - return MediatorResult.Success(endOfPaginationReached = true) - } - val result = - service - .usersListsList( - UsersListsListRequest(), - ).orEmpty() - .map { - it.render() - }.toImmutableList() - - MemoryPagingSource.update( - key = listKey, - value = result.toImmutableList(), - ) - - return MediatorResult.Success( - endOfPaginationReached = true, - ) - } - }, + override fun listTimeline(listId: String) = + ListTimelineRemoteMediator( + listId, + service, + database, + accountKey, ) - override suspend fun createList(metaData: ListMetaData) { - tryRun { - service - .usersListsCreate( - UsersListsCreateRequest( - name = metaData.title, - ), - ) - }.onSuccess { response -> - MemoryPagingSource.updateWith( - key = listKey, - ) { - it - .plus( - UiList( - id = response.id, - title = metaData.title, - platformType = PlatformType.Mastodon, - ), - ).toImmutableList() - } - } + val listLoader: ListLoader by lazy { + MisskeyListLoader( + service = service, + accountKey = accountKey, + ) } - override suspend fun deleteList(listId: String) { - tryRun { - service.usersListsDelete( - UsersListsDeleteRequest(listId = listId), - ) - }.onSuccess { - MemoryPagingSource.updateWith( - key = listKey, - ) { - it - .filter { list -> list.id != listId } - .toImmutableList() - } - } + val listMemberLoader: ListMemberLoader by lazy { + MisskeyListMemberLoader( + service = service, + accountKey = accountKey, + ) } - override suspend fun updateList( - listId: String, - metaData: ListMetaData, - ) { - tryRun { - service.usersListsUpdate( - UsersListsUpdateRequest( - listId = listId, - name = metaData.title, - ), - ) - }.onSuccess { - MemoryPagingSource.updateWith( - key = listKey, - ) { - it - .map { list -> - if (list.id == listId) { - list.copy(title = metaData.title) - } else { - list - } - }.toImmutableList() - } - } + override val listHandler: ListHandler by lazy { + ListHandler( + pagingKey = listKey, + accountKey = accountKey, + loader = listLoader, + ) } - override fun listInfo(listId: String): CacheData = - MemCacheable( - key = "listInfo_$listId", - fetchSource = { - service - .usersListsShow( - UsersListsShowRequest( - listId = listId, - ), - ).render() - }, + override val listMemberHandler: ListMemberHandler by lazy { + ListMemberHandler( + pagingKey = "list_members_$accountKey", + accountKey = accountKey, + loader = listMemberLoader, ) + } - override fun listMembers( - listId: String, - scope: CoroutineScope, - pageSize: Int, - ): Flow> = - memoryPager( - pageSize = pageSize, - pagingKey = listMemberKey(listId), - scope = scope, - mediator = - object : BaseRemoteMediator() { - override suspend fun doLoad( - loadType: LoadType, - state: PagingState, - ): MediatorResult { - if (loadType == LoadType.PREPEND) { - return MediatorResult.Success(endOfPaginationReached = true) - } - val key = - if (loadType == LoadType.REFRESH) { - null - } else { - MemoryPagingSource - .get(key = listMemberKey(listId)) - ?.lastOrNull() - ?.key - ?.id - } - val result = - service - .usersListsGetMemberships( - UsersListsMembershipRequest( - listId = listId, - untilId = key, - limit = state.config.pageSize, - ), - ).orEmpty() - .map { - it.user.render(accountKey) - } - - if (loadType == LoadType.REFRESH) { - MemoryPagingSource.update( - key = listMemberKey(listId), - value = result.toImmutableList(), - ) - } else if (loadType == LoadType.APPEND) { - MemoryPagingSource.append( - key = listMemberKey(listId), - value = result.toImmutableList(), - ) - } - - return MediatorResult.Success( - endOfPaginationReached = result.isEmpty(), - ) - } - }, + val channelHandler: ListHandler by lazy { + ListHandler( + pagingKey = "followedChannels_$accountKey", + accountKey = accountKey, + loader = + MisskeyChannelLoader( + service = service, + accountKey = accountKey, + source = MisskeyChannelLoader.Source.Followed, + ), ) + } - private fun listMemberKey(listId: String) = "listMembers_$listId" + val myFavoriteChannelHandler: ListHandler by lazy { + ListHandler( + pagingKey = "myFavoriteChannels_$accountKey", + accountKey = accountKey, + loader = + MisskeyChannelLoader( + service = service, + accountKey = accountKey, + source = MisskeyChannelLoader.Source.MyFavorites, + ), + ) + } - private fun userListsKey(userKey: MicroBlogKey) = "userLists_${userKey.id}" + val ownedChannelHandler: ListHandler by lazy { + ListHandler( + pagingKey = "ownedChannels_$accountKey", + accountKey = accountKey, + loader = + MisskeyChannelLoader( + service = service, + accountKey = accountKey, + source = MisskeyChannelLoader.Source.Owned, + ), + ) + } - override suspend fun addMember( - listId: String, - userKey: MicroBlogKey, - ) { + suspend fun followChannel(data: UiList) { tryRun { - service.usersListsPush( - UsersListsPullRequest( - listId = listId, - userId = userKey.id, - ), + service.channelsFollow( + ChannelsFollowRequest(channelId = data.id), ) - val user = - service - .usersShow( - UsersShowRequest( - userId = userKey.id, - ), - ).toDbUser(accountKey.host) - .render(accountKey) - MemoryPagingSource.updateWith( - key = listMemberKey(listId), - ) { - (listOfNotNull(user) + it) - .distinctBy { - it.key - }.toImmutableList() - } - val list = - service - .usersListsShow( - UsersListsShowRequest( - listId = listId, - ), - ) - MemCacheable.updateWith>( - key = userListsKey(userKey), - ) { - it - .plus(list.render()) - .toImmutableList() - } + channelHandler.insertToDatabase(data) } } - override suspend fun removeMember( - listId: String, - userKey: MicroBlogKey, - ) { + suspend fun unfollowChannel(data: UiList) { tryRun { - service.usersListsPull( - UsersListsPullRequest( - listId = listId, - userId = userKey.id, - ), + service.channelsUnfollow( + ChannelsFollowRequest(channelId = data.id), ) - MemoryPagingSource.updateWith( - key = listMemberKey(listId), - ) { - it - .filter { user -> user.key.id != userKey.id } - .toImmutableList() - } - MemCacheable.updateWith>( - key = userListsKey(userKey), - ) { - it - .filter { list -> list.id != listId } - .toImmutableList() - } + channelHandler.delete(data.id) } } - override fun listTimeline(listId: String) = - ListTimelineRemoteMediator( - listId, - service, - database, - accountKey, - ) - - override fun listMemberCache(listId: String): Flow> = - MemoryPagingSource.getFlow(listMemberKey(listId)) - - override fun userLists(userKey: MicroBlogKey): MemCacheable> = - MemCacheable( - key = userListsKey(userKey), - ) { - service - .usersListsList( - UsersListsListRequest(), - ).orEmpty() - .filter { - it.userIds?.contains(userKey.id) == true - }.map { - it.render() - }.toImmutableList() + suspend fun favoriteChannel(data: UiList) { + tryRun { + service.channelsFavorite( + ChannelsFollowRequest(channelId = data.id), + ) + myFavoriteChannelHandler.insertToDatabase(data) } + } - override val supportedMetaData: ImmutableList - get() = persistentListOf(ListMetaDataType.TITLE) + suspend fun unfavoriteChannel(data: UiList) { + tryRun { + service.channelsUnfavorite( + ChannelsFollowRequest(channelId = data.id), + ) + myFavoriteChannelHandler.delete(data.id) + } + } override fun acceptFollowRequest( userKey: MicroBlogKey, @@ -1375,34 +1249,25 @@ internal class MisskeyDataSource( } } - fun antennasList( - scope: CoroutineScope, - pageSize: Int = 20, - ): Flow> = + fun antennasList(): Flow> = Pager( config = pagingConfig, ) { AntennasListPagingSource( service = service, ) - }.flow.cachedIn(scope) + }.flow - fun antennasTimeline( - id: String, - scope: CoroutineScope, - pageSize: Int = 20, - ): Flow> = - timelinePager( - pageSize = pageSize, + fun antennasTimelineLoader(id: String) = + AntennasTimelineRemoteMediator( + service = service, database = database, - scope = scope, - filterFlow = localFilterRepository.getFlow(forTimeline = true), - accountRepository = accountRepository, - mediator = antennasTimelineLoader(id), + accountKey = accountKey, + id = id, ) - fun antennasTimelineLoader(id: String) = - AntennasTimelineRemoteMediator( + fun channelTimelineLoader(id: String) = + ChannelTimelineRemoteMediator( service = service, database = database, accountKey = accountKey, 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 new file mode 100644 index 000000000..4a48ef8b4 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyListLoader.kt @@ -0,0 +1,91 @@ +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.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.UsersListsCreateRequest +import dev.dimension.flare.data.network.misskey.api.model.UsersListsDeleteRequest +import dev.dimension.flare.data.network.misskey.api.model.UsersListsListRequest +import dev.dimension.flare.data.network.misskey.api.model.UsersListsShowRequest +import dev.dimension.flare.data.network.misskey.api.model.UsersListsUpdateRequest +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiList +import dev.dimension.flare.ui.model.mapper.render +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +internal class MisskeyListLoader( + private val service: MisskeyService, + private val accountKey: MicroBlogKey, +) : ListLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + if (request is PagingRequest.Prepend) { + return PagingResult() + } + val result = + service + .usersListsList( + UsersListsListRequest(), + ).orEmpty() + .map { + it.render() + }.toImmutableList() + + return PagingResult( + data = result, + nextKey = null, + ) + } + + override suspend fun info(listId: String): UiList = + service + .usersListsShow( + UsersListsShowRequest( + listId = listId, + ), + ).render() + + override suspend fun create(metaData: ListMetaData): UiList { + val response = + service.usersListsCreate( + UsersListsCreateRequest( + name = metaData.title, + ), + ) + return UiList.List( + id = response.id, + title = metaData.title, + description = null, + avatar = null, + creator = null, + ) + } + + override suspend fun update( + listId: String, + metaData: ListMetaData, + ): UiList = + service + .usersListsUpdate( + UsersListsUpdateRequest( + listId = listId, + name = metaData.title, + ), + ).render() + + override suspend fun delete(listId: String) { + service.usersListsDelete( + UsersListsDeleteRequest(listId = listId), + ) + } + + override val supportedMetaData: ImmutableList + get() = persistentListOf(ListMetaDataType.TITLE) +} 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 new file mode 100644 index 000000000..3b7fc3f42 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyListMemberLoader.kt @@ -0,0 +1,113 @@ +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.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.UsersListsListRequest +import dev.dimension.flare.data.network.misskey.api.model.UsersListsMembershipRequest +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.mapper.render +import kotlinx.collections.immutable.toImmutableList + +internal class MisskeyListMemberLoader( + private val service: MisskeyService, + private val accountKey: MicroBlogKey, +) : ListMemberLoader { + override suspend fun loadMembers( + pageSize: Int, + request: PagingRequest, + listId: String, + ): PagingResult { + val cursor = + when (request) { + is PagingRequest.Append -> request.nextKey + is PagingRequest.Refresh -> null + is PagingRequest.Prepend -> return PagingResult() + } + + val response = + service + .usersListsGetMemberships( + UsersListsMembershipRequest( + listId = listId, + untilId = cursor, + limit = pageSize, + ), + ).orEmpty() + + val users = + response.map { + it.user.toDbUser(accountKey.host) + } + + return PagingResult( + data = users, + nextKey = response.lastOrNull()?.id, + ) + } + + override suspend fun addMember( + listId: String, + userKey: MicroBlogKey, + ): DbUser { + service.usersListsPush( + UsersListsPullRequest( + listId = listId, + userId = userKey.id, + ), + ) + return service + .usersShow( + UsersShowRequest( + userId = userKey.id, + ), + ).toDbUser(accountKey.host) + } + + override suspend fun removeMember( + listId: String, + userKey: MicroBlogKey, + ) { + service.usersListsPull( + UsersListsPullRequest( + listId = listId, + userId = userKey.id, + ), + ) + } + + override suspend fun loadUserLists( + pageSize: Int, + request: PagingRequest, + userKey: MicroBlogKey, + ): PagingResult { + if (request is PagingRequest.Prepend) { + return PagingResult() + } + if (request is PagingRequest.Append) { + return PagingResult(nextKey = null) + } + + val result = + service + .usersListsList( + UsersListsListRequest(), + ).orEmpty() + .filter { + it.userIds?.contains(userKey.id) == true + }.map { + it.render() + }.toImmutableList() + + return PagingResult( + data = result, + nextKey = null, + ) + } +} 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 843c42ffc..7aba2c3d9 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -20,14 +23,14 @@ internal class NotificationRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - is Request.Prepend -> return Result( + is PagingRequest.Prepend -> return PagingResult( endOfPaginationReached = true, ) - Request.Refresh -> { + PagingRequest.Refresh -> { service.iNotifications( INotificationsRequest( limit = pageSize, @@ -35,7 +38,7 @@ internal class NotificationRemoteMediator( ) } - is Request.Append -> { + is PagingRequest.Append -> { service.iNotifications( INotificationsRequest( limit = pageSize, @@ -45,7 +48,7 @@ internal class NotificationRemoteMediator( } } - return Result( + return PagingResult( endOfPaginationReached = response.isEmpty(), data = response.toDb( 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 a02a9de24..0e54e4601 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -20,14 +23,14 @@ internal class PublicTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - is Request.Prepend -> return Result( + is PagingRequest.Prepend -> return PagingResult( endOfPaginationReached = true, ) - Request.Refresh -> { + PagingRequest.Refresh -> { service.notesGlobalTimeline( NotesGlobalTimelineRequest( limit = pageSize, @@ -35,7 +38,7 @@ internal class PublicTimelineRemoteMediator( ) } - is Request.Append -> { + is PagingRequest.Append -> { service.notesGlobalTimeline( NotesGlobalTimelineRequest( limit = pageSize, @@ -45,7 +48,7 @@ internal class PublicTimelineRemoteMediator( } } - return Result( + return PagingResult( endOfPaginationReached = response.isEmpty(), data = response.toDbPagingTimeline( 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 1d78a4a9a..ab5f7e6c7 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -26,17 +29,17 @@ internal class SearchStatusRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - Request.Refresh -> { + PagingRequest.Refresh -> { service .notesSearch( NotesSearchRequest( @@ -46,7 +49,7 @@ internal class SearchStatusRemoteMediator( ) } - is Request.Append -> { + is PagingRequest.Append -> { service.notesSearch( NotesSearchRequest( query = query, @@ -57,7 +60,7 @@ internal class SearchStatusRemoteMediator( } } - return Result( + return PagingResult( endOfPaginationReached = response.isEmpty(), data = response.toDbPagingTimeline( 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 8b5e2821d..e4bd78769 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 @@ -5,17 +5,17 @@ import dev.dimension.flare.common.BasePagingSource import dev.dimension.flare.data.network.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.UsersSearchRequest import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiUserV2 +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.mapper.render internal class SearchUserPagingSource( private val service: MisskeyService, private val accountKey: MicroBlogKey, private val query: String, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): Int? = null +) : BasePagingSource() { + override fun getRefreshKey(state: PagingState): Int? = null - override suspend fun doLoad(params: LoadParams): LoadResult { + override suspend fun doLoad(params: LoadParams): LoadResult { service .usersSearch( UsersSearchRequest( 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 404b1b3ca..76105d9b9 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 @@ -2,11 +2,14 @@ package dev.dimension.flare.data.datasource.misskey import SnowflakeIdGenerator import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -37,13 +40,13 @@ internal class StatusDetailRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val result = when (request) { - is Request.Append -> { + is PagingRequest.Append -> { if (statusOnly) { - return Result( + return PagingResult( endOfPaginationReached = true, ) } @@ -57,12 +60,12 @@ internal class StatusDetailRemoteMediator( ) } - is Request.Prepend -> - return Result( + is PagingRequest.Prepend -> + return PagingResult( endOfPaginationReached = true, ) - Request.Refresh -> { + PagingRequest.Refresh -> { if (!database.pagingTimelineDao().existsPaging(accountKey, pagingKey)) { val status = database @@ -99,7 +102,7 @@ internal class StatusDetailRemoteMediator( } } - return Result( + return PagingResult( endOfPaginationReached = statusOnly || result.isEmpty(), data = result.toDbPagingTimeline( @@ -110,7 +113,7 @@ internal class StatusDetailRemoteMediator( }, ), nextKey = - if (request == Request.Refresh) { + if (request == PagingRequest.Refresh) { "" } else { result.lastOrNull()?.id 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 7465cc77a..e1cdbdf7e 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 @@ -5,16 +5,16 @@ import dev.dimension.flare.common.BasePagingSource import dev.dimension.flare.data.network.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.PinnedUsersRequest import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiUserV2 +import dev.dimension.flare.ui.model.UiProfile 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 +) : BasePagingSource() { + override fun getRefreshKey(state: PagingState): Int? = null - override suspend fun doLoad(params: LoadParams): LoadResult { + override suspend fun doLoad(params: LoadParams): LoadResult { service .pinnedUsers(PinnedUsersRequest(limit = params.loadSize)) .map { 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 1325d12ce..5eccba686 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.misskey import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -43,15 +46,15 @@ internal class UserTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - is Request.Prepend -> return Result( + is PagingRequest.Prepend -> return PagingResult( endOfPaginationReached = true, ) - Request.Refresh -> { + PagingRequest.Refresh -> { val pinned = if (withPinned) { service @@ -91,7 +94,7 @@ internal class UserTimelineRemoteMediator( } } - is Request.Append -> { + is PagingRequest.Append -> { service .usersNotes( UsersNotesRequest( @@ -117,7 +120,7 @@ internal class UserTimelineRemoteMediator( } } - return Result( + return PagingResult( endOfPaginationReached = response.isEmpty(), data = response.toDbPagingTimeline( 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 f00399ab6..ad664fce1 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,13 +1,16 @@ package dev.dimension.flare.data.datasource.rss import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -37,8 +40,8 @@ internal class RssTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val rssSource = fetchSource(url) val response = fetchFeed(url) val title = rssSource?.title ?: response.title @@ -159,7 +162,7 @@ internal class RssTimelineRemoteMediator( ) } - return Result( + return PagingResult( endOfPaginationReached = true, data = content, ) 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 c2da6d2ad..97c79b417 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 @@ -2,9 +2,12 @@ package dev.dimension.flare.data.datasource.vvo import SnowflakeIdGenerator import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -23,8 +26,8 @@ internal class CommentChildRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val config = service.config() if (config.data?.login != true) { throw LoginExpiredException( @@ -34,20 +37,20 @@ internal class CommentChildRemoteMediator( } val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .getHotFlowChild( cid = commentKey.id, ) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.getHotFlowChild( cid = commentKey.id, maxId = request.nextKey.toLongOrNull(), @@ -69,7 +72,7 @@ internal class CommentChildRemoteMediator( ) } - return Result( + return PagingResult( endOfPaginationReached = maxId == null, data = data, nextKey = maxId?.toString(), 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 df15d4d33..d232937de 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 @@ -2,9 +2,12 @@ package dev.dimension.flare.data.datasource.vvo import SnowflakeIdGenerator import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -23,8 +26,8 @@ internal class DiscoverStatusRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val config = service.config() if (config.data?.login != true) { throw LoginExpiredException( @@ -35,24 +38,24 @@ internal class DiscoverStatusRemoteMediator( val page = when (request) { - is Request.Append -> request.nextKey.toIntOrNull() ?: 0 - is Request.Prepend -> 0 - Request.Refresh -> 0 + is PagingRequest.Append -> request.nextKey.toIntOrNull() ?: 0 + is PagingRequest.Prepend -> 0 + PagingRequest.Refresh -> 0 } val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service.getContainerIndex(containerId = containerId) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.getContainerIndex(containerId = containerId, sinceId = page.toString()) } } @@ -74,7 +77,7 @@ internal class DiscoverStatusRemoteMediator( ) } - return Result( + return PagingResult( endOfPaginationReached = status.isEmpty(), data = data, 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 a93ea0f75..02741a4a5 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 @@ -4,15 +4,15 @@ import androidx.paging.PagingState import dev.dimension.flare.common.BasePagingSource import dev.dimension.flare.data.network.vvo.VVOService import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiUserV2 +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.mapper.render internal class FansPagingSource( private val service: VVOService, private val accountKey: MicroBlogKey, private val userKey: MicroBlogKey, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): Int? = null +) : BasePagingSource() { + override fun getRefreshKey(state: PagingState): Int? = null private val containerId by lazy { if (accountKey == userKey) { @@ -22,7 +22,7 @@ internal class FansPagingSource( } } - override suspend fun doLoad(params: LoadParams): LoadResult { + override suspend fun doLoad(params: LoadParams): LoadResult { val nextPage = params.key ?: 0 val limit = params.loadSize val users = 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 8eec32443..f3953ae16 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.vvo import SnowflakeIdGenerator -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -21,8 +24,8 @@ internal class FavouriteRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val config = service.config() if (config.data?.login != true) { throw LoginExpiredException( @@ -33,23 +36,23 @@ internal class FavouriteRemoteMediator( val page = when (request) { - is Request.Append -> request.nextKey.toIntOrNull() + is PagingRequest.Append -> request.nextKey.toIntOrNull() else -> null } val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service.getContainerIndex(containerId = containerId) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.getContainerIndex( containerId = containerId, page = page, @@ -77,7 +80,7 @@ internal class FavouriteRemoteMediator( } val nextKey = response.data?.cardlistInfo?.page - return Result( + return PagingResult( endOfPaginationReached = nextKey == null, data = data, 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 cb1e9449d..e07644db7 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 @@ -4,15 +4,15 @@ import androidx.paging.PagingState import dev.dimension.flare.common.BasePagingSource import dev.dimension.flare.data.network.vvo.VVOService import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiUserV2 +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.mapper.render internal class FollowingPagingSource( private val service: VVOService, private val accountKey: MicroBlogKey, private val userKey: MicroBlogKey, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): Int? = null +) : BasePagingSource() { + override fun getRefreshKey(state: PagingState): Int? = null private val containerId by lazy { if (accountKey == userKey) { @@ -22,7 +22,7 @@ internal class FollowingPagingSource( } } - override suspend fun doLoad(params: LoadParams): LoadResult { + override suspend fun doLoad(params: LoadParams): LoadResult { val nextPage = params.key ?: 1 val limit = params.loadSize val users = 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 2b48785e2..b4d54c12a 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 @@ -1,11 +1,14 @@ package dev.dimension.flare.data.datasource.vvo import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -26,8 +29,8 @@ internal class HomeTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val config = service.config() if (config.data?.login != true) { inAppNotification.onError( @@ -45,17 +48,17 @@ internal class HomeTimelineRemoteMediator( val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service.getFriendsTimeline() } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.getFriendsTimeline( maxId = request.nextKey, ) @@ -71,7 +74,7 @@ internal class HomeTimelineRemoteMediator( ) } - return Result( + return PagingResult( endOfPaginationReached = response.data?.nextCursorStr == null, data = data, nextKey = response.data?.nextCursorStr, 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 f300e16fa..d61610edc 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.vvo import SnowflakeIdGenerator -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -21,8 +24,8 @@ internal class LikeRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val config = service.config() if (config.data?.login != true) { throw LoginExpiredException( @@ -33,23 +36,23 @@ internal class LikeRemoteMediator( val page = when (request) { - is Request.Append -> request.nextKey.toIntOrNull() + is PagingRequest.Append -> request.nextKey.toIntOrNull() else -> null } val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service.getContainerIndex(containerId = containerId) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.getContainerIndex( containerId = containerId, page = page, @@ -84,7 +87,7 @@ internal class LikeRemoteMediator( } val nextKey = response.data?.cardlistInfo?.page - return Result( + return PagingResult( endOfPaginationReached = nextKey == null, data = data, 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 5ca83079c..452203a9e 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.vvo import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -22,8 +25,8 @@ internal class MentionRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val config = service.config() if (config.data?.login != true) { throw LoginExpiredException( @@ -34,16 +37,16 @@ internal class MentionRemoteMediator( val page = when (request) { - Request.Refresh -> 0 - is Request.Prepend -> return Result( + PagingRequest.Refresh -> 0 + is PagingRequest.Prepend -> return PagingResult( endOfPaginationReached = true, ) - is Request.Append -> request.nextKey.toIntOrNull() ?: 0 + is PagingRequest.Append -> request.nextKey.toIntOrNull() ?: 0 } val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { val result = service .getMentionsAt( @@ -53,13 +56,13 @@ internal class MentionRemoteMediator( result } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.getMentionsAt( page = page, ) @@ -75,7 +78,7 @@ internal class MentionRemoteMediator( ) } - return Result( + return PagingResult( endOfPaginationReached = response.data.isNullOrEmpty(), 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 99dc1a300..c272dadbb 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.vvo import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -31,8 +34,8 @@ internal class SearchStatusRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val config = service.config() if (config.data?.login != true) { throw LoginExpiredException( @@ -43,14 +46,14 @@ internal class SearchStatusRemoteMediator( val page = when (request) { - is Request.Append -> request.nextKey.toIntOrNull() ?: 1 - is Request.Prepend -> 1 - Request.Refresh -> 1 + is PagingRequest.Append -> request.nextKey.toIntOrNull() ?: 1 + is PagingRequest.Prepend -> 1 + PagingRequest.Refresh -> 1 } val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .getContainerIndex( containerId = containerId, @@ -58,13 +61,13 @@ internal class SearchStatusRemoteMediator( ) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.getContainerIndex( containerId = containerId, pageType = "searchall", @@ -91,7 +94,7 @@ internal class SearchStatusRemoteMediator( ) } - return Result( + return PagingResult( endOfPaginationReached = status.isEmpty(), data = data, 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 939291430..542857b23 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 @@ -6,21 +6,21 @@ 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.UiUserV2 +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.mapper.render internal class SearchUserPagingSource( private val service: VVOService, private val accountKey: MicroBlogKey, private val query: String, -) : BasePagingSource() { +) : BasePagingSource() { private val containerId by lazy { "100103type=3&q=$query&t=" } - override fun getRefreshKey(state: PagingState): Int? = null + override fun getRefreshKey(state: PagingState): Int? = null - override suspend fun doLoad(params: LoadParams): LoadResult { + override suspend fun doLoad(params: LoadParams): LoadResult { val config = service.config() if (config.data?.login != true) { return LoadResult.Error( 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 c6fe3c3ef..7add6deaa 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 @@ -2,9 +2,12 @@ package dev.dimension.flare.data.datasource.vvo import SnowflakeIdGenerator import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -23,8 +26,8 @@ internal class StatusCommentRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val config = service.config() if (config.data?.login != true) { throw LoginExpiredException( @@ -35,7 +38,7 @@ internal class StatusCommentRemoteMediator( val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .getHotComments( id = statusKey.id, @@ -44,13 +47,13 @@ internal class StatusCommentRemoteMediator( ) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.getHotComments( id = statusKey.id, mid = statusKey.id, @@ -73,7 +76,7 @@ internal class StatusCommentRemoteMediator( ) } - return Result( + return PagingResult( endOfPaginationReached = maxId == null, data = data, 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 c4a630437..95ea72b06 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,9 +1,12 @@ package dev.dimension.flare.data.datasource.vvo import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -22,8 +25,8 @@ internal class StatusRepostRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val config = service.config() if (config.data?.login != true) { throw LoginExpiredException( @@ -34,14 +37,14 @@ internal class StatusRepostRemoteMediator( val page = when (request) { - Request.Refresh -> 1 - is Request.Append -> request.nextKey.toIntOrNull() ?: 1 - is Request.Prepend -> return Result(endOfPaginationReached = true) + PagingRequest.Refresh -> 1 + is PagingRequest.Append -> request.nextKey.toIntOrNull() ?: 1 + is PagingRequest.Prepend -> return PagingResult(endOfPaginationReached = true) } val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .getRepostTimeline( id = statusKey.id, @@ -49,13 +52,13 @@ internal class StatusRepostRemoteMediator( ) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.getRepostTimeline( id = statusKey.id, page = page, @@ -80,7 +83,7 @@ internal class StatusRepostRemoteMediator( ) } - return Result( + return PagingResult( endOfPaginationReached = statuses.isEmpty(), data = data, nextKey = (page + 1).toString(), 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 4bd2f48eb..212370de3 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 @@ -2,9 +2,12 @@ package dev.dimension.flare.data.datasource.vvo import SnowflakeIdGenerator import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 @@ -34,11 +37,11 @@ internal class UserTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { if (mediaOnly) { // Not supported yet - return Result( + return PagingResult( endOfPaginationReached = true, ) } @@ -63,7 +66,7 @@ internal class UserTimelineRemoteMediator( } val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .getContainerIndex( type = "uid", @@ -72,13 +75,13 @@ internal class UserTimelineRemoteMediator( ) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.getContainerIndex( type = "uid", value = userKey.id, @@ -104,7 +107,7 @@ internal class UserTimelineRemoteMediator( ) } - return Result( + return PagingResult( endOfPaginationReached = response.data?.cardlistInfo?.sinceID == null, data = data, nextKey = 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 35b764204..30830af66 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 @@ -4,7 +4,6 @@ import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingData import androidx.paging.cachedIn -import dev.dimension.flare.common.BaseTimelineLoader import dev.dimension.flare.common.CacheData import dev.dimension.flare.common.Cacheable import dev.dimension.flare.common.FileItem @@ -29,6 +28,7 @@ import dev.dimension.flare.data.datasource.microblog.NotificationFilter import dev.dimension.flare.data.datasource.microblog.ProfileAction 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 @@ -49,7 +49,6 @@ 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.UiUserV2 import dev.dimension.flare.ui.model.mapper.render import dev.dimension.flare.ui.model.mapper.toUi import dev.dimension.flare.ui.model.toUi @@ -161,7 +160,7 @@ internal class VVODataSource( NotificationFilter.Like, ) - override fun userByAcct(acct: String): CacheData { + override fun userByAcct(acct: String): CacheData { val (name, host) = MicroBlogKey.valueOf(acct.removePrefix("@")) return Cacheable( fetchSource = { @@ -443,7 +442,7 @@ internal class VVODataSource( override fun searchUser( query: String, pageSize: Int, - ): Flow> = + ): Flow> = Pager( config = pagingConfig, ) { @@ -454,7 +453,7 @@ internal class VVODataSource( ) }.flow - override fun discoverUsers(pageSize: Int): Flow> { + override fun discoverUsers(pageSize: Int): Flow> { TODO("Not yet implemented") } @@ -924,7 +923,7 @@ internal class VVODataSource( userKey: MicroBlogKey, scope: CoroutineScope, pageSize: Int, - ): Flow> = + ): Flow> = Pager( config = pagingConfig, ) { @@ -939,7 +938,7 @@ internal class VVODataSource( userKey: MicroBlogKey, scope: CoroutineScope, pageSize: Int, - ): Flow> = + ): Flow> = Pager( config = pagingConfig, ) { 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 c76fa1078..43f8fcabb 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,11 +1,14 @@ package dev.dimension.flare.data.datasource.xqt import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.xqt.XQTService import dev.dimension.flare.model.MicroBlogKey @@ -21,24 +24,24 @@ internal class DeviceFollowRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .getNotificationsDeviceFollow( count = pageSize, ) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.getNotificationsDeviceFollow( count = pageSize, cursor = request.nextKey, @@ -49,7 +52,7 @@ internal class DeviceFollowRemoteMediator( val data = tweets.mapNotNull { it.toDbPagingTimeline(accountKey, pagingKey) } - return Result( + return PagingResult( endOfPaginationReached = tweets.isEmpty(), data = data, 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 84d30094c..314a116d0 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 @@ -7,7 +7,7 @@ import dev.dimension.flare.data.database.cache.mapper.cursor import dev.dimension.flare.data.database.cache.mapper.users import dev.dimension.flare.data.network.xqt.XQTService import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiUserV2 +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.mapper.render import kotlinx.serialization.Required import kotlinx.serialization.SerialName @@ -17,10 +17,10 @@ internal class FansPagingSource( private val service: XQTService, private val accountKey: MicroBlogKey, private val userKey: MicroBlogKey, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null +) : BasePagingSource() { + override fun getRefreshKey(state: PagingState): String? = null - override suspend fun doLoad(params: LoadParams): LoadResult { + override suspend fun doLoad(params: LoadParams): LoadResult { val cursor = params.key val limit = params.loadSize val response = 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 072ab4f60..228c8dfcf 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 @@ -7,17 +7,17 @@ import dev.dimension.flare.data.database.cache.mapper.cursor import dev.dimension.flare.data.database.cache.mapper.users import dev.dimension.flare.data.network.xqt.XQTService import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiUserV2 +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.mapper.render internal class FollowingPagingSource( private val service: XQTService, private val accountKey: MicroBlogKey, private val userKey: MicroBlogKey, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null +) : BasePagingSource() { + override fun getRefreshKey(state: PagingState): String? = null - override suspend fun doLoad(params: LoadParams): LoadResult { + override suspend fun doLoad(params: LoadParams): LoadResult { val cursor = params.key val limit = params.loadSize val response = 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 20a666b1b..59b4c00a1 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 @@ -1,7 +1,6 @@ package dev.dimension.flare.data.datasource.xqt import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator import dev.dimension.flare.common.InAppNotification import dev.dimension.flare.common.Message import dev.dimension.flare.common.encodeJson @@ -9,6 +8,10 @@ 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.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 @@ -30,11 +33,11 @@ internal class HomeTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .getHomeLatestTimeline( variables = @@ -44,13 +47,13 @@ internal class HomeTimelineRemoteMediator( ) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.getHomeLatestTimeline( variables = HomeTimelineRequest( @@ -72,7 +75,7 @@ internal class HomeTimelineRemoteMediator( val data = tweet.mapNotNull { it.toDbPagingTimeline(accountKey, pagingKey) } - return Result( + return PagingResult( endOfPaginationReached = tweet.isEmpty(), data = data, nextKey = cursor, @@ -104,11 +107,11 @@ internal class FeaturedTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .getHomeTimeline( variables = @@ -118,13 +121,13 @@ internal class FeaturedTimelineRemoteMediator( ) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.getHomeTimeline( variables = HomeTimelineRequest( @@ -145,7 +148,7 @@ internal class FeaturedTimelineRemoteMediator( val data = tweet.mapNotNull { it.toDbPagingTimeline(accountKey, pagingKey) } - return Result( + return PagingResult( endOfPaginationReached = tweet.isEmpty(), data = data, nextKey = instructions.cursor(), @@ -165,11 +168,11 @@ internal class BookmarkTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .getBookmarks( variables = @@ -179,13 +182,13 @@ internal class BookmarkTimelineRemoteMediator( ) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.getBookmarks( variables = HomeTimelineRequest( @@ -206,7 +209,7 @@ internal class BookmarkTimelineRemoteMediator( val data = tweet.mapNotNull { it.toDbPagingTimeline(accountKey, pagingKey) } - return Result( + return PagingResult( endOfPaginationReached = tweet.isEmpty(), data = data, nextKey = instructions.cursor(), 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 36639c509..896f825ee 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 @@ -1,13 +1,15 @@ package dev.dimension.flare.data.datasource.xqt import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator -import dev.dimension.flare.common.BaseTimelineRemoteMediator.Request 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.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 kotlinx.serialization.SerialName @@ -34,11 +36,11 @@ internal class ListTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: BaseTimelineRemoteMediator.Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - BaseTimelineRemoteMediator.Request.Refresh -> { + PagingRequest.Refresh -> { service .getListLatestTweetsTimeline( variables = @@ -49,13 +51,13 @@ internal class ListTimelineRemoteMediator( ) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.getListLatestTweetsTimeline( variables = Request( @@ -70,7 +72,7 @@ internal class ListTimelineRemoteMediator( val data = result.mapNotNull { it.toDbPagingTimeline(accountKey, pagingKey) } - return Result( + return PagingResult( endOfPaginationReached = response.isEmpty(), data = data, 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 857ad5696..d56913f1d 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,11 +1,14 @@ package dev.dimension.flare.data.datasource.xqt import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.xqt.XQTService import dev.dimension.flare.model.MicroBlogKey @@ -21,24 +24,24 @@ internal class MentionRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .getNotificationsMentions( count = pageSize, ) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.getNotificationsMentions( count = pageSize, cursor = request.nextKey, @@ -49,7 +52,7 @@ internal class MentionRemoteMediator( val data = tweets.mapNotNull { it.toDbPagingTimeline(accountKey, pagingKey) } - return Result( + return PagingResult( endOfPaginationReached = tweets.isEmpty(), data = data, nextKey = response.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 9790dcd28..638051d11 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,13 +1,16 @@ package dev.dimension.flare.data.datasource.xqt import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.network.xqt.XQTService import dev.dimension.flare.model.MicroBlogKey import io.ktor.http.encodeURLQueryComponent @@ -27,11 +30,11 @@ internal class SearchStatusPagingSource( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .getSearchTimeline( variables = @@ -43,13 +46,13 @@ internal class SearchStatusPagingSource( ) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.getSearchTimeline( variables = SearchRequest( @@ -65,7 +68,7 @@ internal class SearchStatusPagingSource( val data = tweets.mapNotNull { it.toDbPagingTimeline(accountKey, pagingKey) } - return Result( + return PagingResult( endOfPaginationReached = response.isBottomEnd(), data = data, 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 d13f0fe57..e75837d14 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 @@ -8,7 +8,7 @@ import dev.dimension.flare.data.database.cache.mapper.isBottomEnd import dev.dimension.flare.data.database.cache.mapper.users import dev.dimension.flare.data.network.xqt.XQTService import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiUserV2 +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.mapper.render import io.ktor.http.encodeURLQueryComponent @@ -16,10 +16,10 @@ internal class SearchUserPagingSource( private val service: XQTService, private val accountKey: MicroBlogKey, private val query: String, -) : BasePagingSource() { - override fun getRefreshKey(state: PagingState): String? = null +) : BasePagingSource() { + override fun getRefreshKey(state: PagingState): String? = null - override suspend fun doLoad(params: LoadParams): LoadResult { + override suspend fun doLoad(params: LoadParams): LoadResult { val response = service .getSearchTimeline( 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 b7bbd8822..b21bdbf85 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 @@ -1,7 +1,6 @@ package dev.dimension.flare.data.datasource.xqt import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator import dev.dimension.flare.common.encodeJson import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.connect @@ -10,7 +9,11 @@ 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.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 @@ -47,12 +50,12 @@ internal class StatusDetailRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { when (request) { - is Request.Append -> { + is PagingRequest.Append -> { if (statusOnly) { - return Result( + return PagingResult( endOfPaginationReached = true, ) } else { @@ -107,19 +110,19 @@ internal class StatusDetailRemoteMediator( } val result = actualTweet.mapNotNull { it.toDbPagingTimeline(accountKey, pagingKey) } - return Result( + return PagingResult( endOfPaginationReached = response.isBottomEnd() || actualTweet.size == 1 || response.cursor() == null, data = result, nextKey = response.cursor(), ) } } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - Request.Refresh -> { + PagingRequest.Refresh -> { if (!database.pagingTimelineDao().existsPaging(accountKey, pagingKey)) { database.statusDao().get(statusKey, AccountType.Specific(accountKey)).firstOrNull()?.let { database.connect { @@ -156,7 +159,7 @@ internal class StatusDetailRemoteMediator( val item = tweet.firstOrNull { it.id == statusKey.id } val result = listOf(item).mapNotNull { it?.toDbPagingTimeline(accountKey, pagingKey) } - return Result( + return PagingResult( endOfPaginationReached = statusOnly, data = result, nextKey = "", 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 4f7821dd4..3c88d0764 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 @@ -5,16 +5,16 @@ import dev.dimension.flare.common.BasePagingSource import dev.dimension.flare.data.network.xqt.XQTService import dev.dimension.flare.data.network.xqt.model.User import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiUserV2 +import dev.dimension.flare.ui.model.UiProfile 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 +) : BasePagingSource() { + override fun getRefreshKey(state: PagingState): Int? = null - override suspend fun doLoad(params: LoadParams): LoadResult { + override suspend fun doLoad(params: LoadParams): LoadResult { service .getUserRecommendations( limit = params.loadSize, 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 680efc024..19e3a6fed 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 @@ -1,12 +1,15 @@ package dev.dimension.flare.data.datasource.xqt import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.xqt.XQTService import dev.dimension.flare.model.MicroBlogKey @@ -23,11 +26,11 @@ internal class UserLikesTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .getLikes( variables = @@ -38,13 +41,13 @@ internal class UserLikesTimelineRemoteMediator( ) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.getLikes( variables = UserTimelineRequest( @@ -66,12 +69,12 @@ internal class UserLikesTimelineRemoteMediator( .orEmpty() val tweet = instructions.tweets( - includePin = request is Request.Refresh, + includePin = request is PagingRequest.Refresh, ) val data = tweet.mapNotNull { it.toDbPagingTimeline(accountKey, pagingKey) } - return Result( + return PagingResult( endOfPaginationReached = tweet.isEmpty(), data = data, 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 668433f1c..865e0d162 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 @@ -1,12 +1,15 @@ package dev.dimension.flare.data.datasource.xqt import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.xqt.XQTService import dev.dimension.flare.model.MicroBlogKey @@ -23,11 +26,11 @@ internal class UserMediaTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .getUserMedia( variables = @@ -38,13 +41,13 @@ internal class UserMediaTimelineRemoteMediator( ) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.getUserMedia( variables = UserTimelineRequest( @@ -66,7 +69,7 @@ internal class UserMediaTimelineRemoteMediator( .orEmpty() val tweet = instructions.tweets( - includePin = request is Request.Refresh, + includePin = request is PagingRequest.Refresh, ) val data = @@ -80,7 +83,7 @@ internal class UserMediaTimelineRemoteMediator( ) } - return Result( + return PagingResult( endOfPaginationReached = tweet.isEmpty(), data = data, 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 202876185..fe2897bac 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 @@ -1,12 +1,15 @@ package dev.dimension.flare.data.datasource.xqt import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.PagingRequest +import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.xqt.XQTService import dev.dimension.flare.model.MicroBlogKey @@ -23,11 +26,11 @@ internal class UserRepliesTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .getUserTweetsAndReplies( variables = @@ -38,13 +41,13 @@ internal class UserRepliesTimelineRemoteMediator( ) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.getUserTweetsAndReplies( variables = UserTimelineRequest( @@ -66,12 +69,12 @@ internal class UserRepliesTimelineRemoteMediator( .orEmpty() val tweet = instructions.tweets( - includePin = request is Request.Refresh, + includePin = request is PagingRequest.Refresh, ) val data = tweet.mapNotNull { it.toDbPagingTimeline(accountKey, pagingKey) } - return Result( + return PagingResult( endOfPaginationReached = tweet.isEmpty(), data = data, 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 9092fcc34..24ca48625 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 @@ -1,12 +1,15 @@ package dev.dimension.flare.data.datasource.xqt import androidx.paging.ExperimentalPagingApi -import dev.dimension.flare.common.BaseTimelineRemoteMediator 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.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 kotlinx.serialization.Required @@ -26,11 +29,11 @@ internal class UserTimelineRemoteMediator( override suspend fun timeline( pageSize: Int, - request: Request, - ): Result { + request: PagingRequest, + ): PagingResult { val response = when (request) { - Request.Refresh -> { + PagingRequest.Refresh -> { service .getUserTweets( variables = @@ -41,13 +44,13 @@ internal class UserTimelineRemoteMediator( ) } - is Request.Prepend -> { - return Result( + is PagingRequest.Prepend -> { + return PagingResult( endOfPaginationReached = true, ) } - is Request.Append -> { + is PagingRequest.Append -> { service.getUserTweets( variables = UserTimelineRequest( @@ -69,12 +72,12 @@ internal class UserTimelineRemoteMediator( .orEmpty() val tweet = instructions.tweets( - includePin = request is Request.Refresh, + includePin = request is PagingRequest.Refresh, ) val data = tweet.mapNotNull { it.toDbPagingTimeline(accountKey, pagingKey) } - return Result( + return PagingResult( endOfPaginationReached = tweet.isEmpty(), data = data, 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 23a62c937..8bf7b76ad 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 @@ -1,14 +1,10 @@ package dev.dimension.flare.data.datasource.xqt import androidx.paging.ExperimentalPagingApi -import androidx.paging.LoadType 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.BaseRemoteMediator -import dev.dimension.flare.common.BaseTimelineLoader import dev.dimension.flare.common.CacheData import dev.dimension.flare.common.Cacheable import dev.dimension.flare.common.FileType @@ -19,10 +15,8 @@ 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.cursor 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.mapper.users 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 @@ -34,26 +28,23 @@ 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.DirectMessageDataSource -import dev.dimension.flare.data.datasource.microblog.ListDataSource -import dev.dimension.flare.data.datasource.microblog.ListMetaData -import dev.dimension.flare.data.datasource.microblog.ListMetaDataType -import dev.dimension.flare.data.datasource.microblog.MemoryPagingSource import dev.dimension.flare.data.datasource.microblog.NotificationFilter import dev.dimension.flare.data.datasource.microblog.ProfileAction 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.memoryPager +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.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.AddMemberRequest import dev.dimension.flare.data.network.xqt.model.AddToConversationRequest import dev.dimension.flare.data.network.xqt.model.CreateBookmarkRequest import dev.dimension.flare.data.network.xqt.model.CreateBookmarkRequestVariables -import dev.dimension.flare.data.network.xqt.model.CreateListRequest import dev.dimension.flare.data.network.xqt.model.DeleteBookmarkRequest import dev.dimension.flare.data.network.xqt.model.DeleteBookmarkRequestVariables import dev.dimension.flare.data.network.xqt.model.LiveVideoStreamStatusResponse @@ -72,9 +63,6 @@ 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.RemoveListRequest -import dev.dimension.flare.data.network.xqt.model.RemoveMemberRequest -import dev.dimension.flare.data.network.xqt.model.UpdateListRequest 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 @@ -88,20 +76,16 @@ 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.UiList 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.UiUserV2 -import dev.dimension.flare.ui.model.mapper.list 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.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CoroutineScope @@ -156,6 +140,24 @@ internal class XQTDataSource( ) } + private val listLoader = XQTListLoader(service, accountKey) + + private val listMemberLoader = XQTListMemberLoader(service, accountKey) + + override val listHandler = + ListHandler( + pagingKey = "list_$accountKey", + accountKey = accountKey, + loader = listLoader, + ) + + override val listMemberHandler = + ListMemberHandler( + pagingKey = "list_member_$accountKey", + accountKey = accountKey, + loader = listMemberLoader, + ) + override fun homeTimeline() = HomeTimelineRemoteMediator( service, @@ -252,7 +254,7 @@ internal class XQTDataSource( override val supportedNotificationFilter: List get() = listOf(NotificationFilter.All, NotificationFilter.Mention) - override fun userByAcct(acct: String): CacheData { + override fun userByAcct(acct: String): CacheData { val (name, host) = MicroBlogKey.valueOf(acct.removePrefix("@")) return Cacheable( fetchSource = { @@ -646,7 +648,7 @@ internal class XQTDataSource( override fun searchUser( query: String, pageSize: Int, - ): Flow> = + ): Flow> = Pager( config = pagingConfig, ) { @@ -657,7 +659,7 @@ internal class XQTDataSource( ) }.flow - override fun discoverUsers(pageSize: Int): Flow> = + override fun discoverUsers(pageSize: Int): Flow> = Pager( config = pagingConfig, ) { @@ -1142,7 +1144,7 @@ internal class XQTDataSource( userKey: MicroBlogKey, scope: CoroutineScope, pageSize: Int, - ): Flow> = + ): Flow> = Pager( config = pagingConfig, ) { @@ -1157,7 +1159,7 @@ internal class XQTDataSource( userKey: MicroBlogKey, scope: CoroutineScope, pageSize: Int, - ): Flow> = + ): Flow> = Pager( config = pagingConfig, ) { @@ -1201,356 +1203,6 @@ internal class XQTDataSource( }, ).toPersistentList() - private val listKey: String - get() = "allLists_$accountKey" - - override fun myList(scope: CoroutineScope): Flow> = - memoryPager( - pageSize = 20, - pagingKey = listKey, - scope = scope, - mediator = - object : BaseRemoteMediator() { - var cursor: String? = null - - override suspend fun doLoad( - loadType: LoadType, - state: PagingState, - ): MediatorResult { - if (loadType == LoadType.PREPEND) { - return MediatorResult.Success(endOfPaginationReached = true) - } - if (loadType == LoadType.REFRESH) { - cursor = null - } - val response = - service - .getListsManagementPageTimeline( - variables = - buildString { - append("{\"count\":20") - if (cursor != null) { - append(",\"cursor\":\"${cursor}\"") - } - append("}") - }, - ).body() - ?.data - ?.viewer - ?.listManagementTimeline - ?.timeline - ?.instructions - - cursor = response?.cursor() - - val result = - response - ?.list(accountKey = accountKey) - .orEmpty() - .toImmutableList() - - if (loadType == LoadType.REFRESH) { - MemoryPagingSource.update( - key = listKey, - value = result, - ) - } else if (loadType == LoadType.APPEND) { - MemoryPagingSource.append( - key = listKey, - value = result, - ) - } - - return MediatorResult.Success( - endOfPaginationReached = result.isEmpty(), - ) - } - }, - ) - - override suspend fun createList(metaData: ListMetaData) { - tryRun { - service.createList( - request = - CreateListRequest( - variables = - CreateListRequest.Variables( - name = metaData.title, - description = metaData.description.orEmpty(), - isPrivate = false, - ), - ), - ) - }.onSuccess { response -> - val data = response.body()?.data?.list - if (data?.idStr != null) { - MemoryPagingSource.updateWith( - key = listKey, - ) { - it - .plus( - UiList( - id = data.idStr, - title = metaData.title, - description = metaData.description, - platformType = PlatformType.Mastodon, - ), - ).toImmutableList() - } - } - } - } - - override suspend fun deleteList(listId: String) { - tryRun { - service.deleteList( - request = - RemoveListRequest( - variables = - RemoveListRequest.Variables( - listID = listId, - ), - ), - ) - }.onSuccess { - MemoryPagingSource.updateWith( - key = listKey, - ) { - it - .filter { list -> list.id != listId } - .toImmutableList() - } - } - } - - override suspend fun updateList( - listId: String, - metaData: ListMetaData, - ) { - tryRun { - service.updateList( - request = - UpdateListRequest( - variables = - UpdateListRequest.Variables( - listID = listId, - name = metaData.title, - description = metaData.description.orEmpty(), - isPrivate = false, - ), - ), - ) - }.onSuccess { - MemoryPagingSource.updateWith( - key = listKey, - ) { - it - .map { list -> - if (list.id == listId) { - list.copy( - title = metaData.title, - description = metaData.description, - ) - } else { - list - } - }.toImmutableList() - } - } - } - - override fun listInfo(listId: String): CacheData = - MemCacheable( - key = "listInfo_$listId", - fetchSource = { - getListInfo(listId) - ?: throw Exception("List not found") - }, - ) - - private suspend fun getListInfo(listId: String) = - service - .getListByRestId( - variables = "{\"listId\":\"${listId}\"}", - ).body() - ?.data - ?.list - ?.render(accountKey = accountKey) - - private fun listMemberKey(listId: String) = "listMembers_$listId" - - override fun listMembers( - listId: String, - scope: CoroutineScope, - pageSize: Int, - ): Flow> = - memoryPager( - pageSize = pageSize, - pagingKey = listMemberKey(listId), - scope = scope, - mediator = - object : BaseRemoteMediator() { - var cursor: String? = null - - override suspend fun doLoad( - loadType: LoadType, - state: PagingState, - ): MediatorResult { - if (loadType == LoadType.PREPEND) { - return MediatorResult.Success(endOfPaginationReached = true) - } - if (loadType == LoadType.REFRESH) { - cursor = null - } - val response = - service - .getListMembers( - variables = - buildString { - append("{\"listId\":\"${listId}\",\"count\":$pageSize") - if (cursor != null) { - append(",\"cursor\":\"${cursor}\"") - } - append("}") - }, - ).body() - ?.data - ?.list - ?.membersTimeline - ?.timeline - ?.instructions - - cursor = response?.cursor() - - val result = - response?.users().orEmpty().map { - it.render(accountKey = accountKey) - } - - if (loadType == LoadType.REFRESH) { - MemoryPagingSource.update( - key = listMemberKey(listId), - value = result.toImmutableList(), - ) - } else if (loadType == LoadType.APPEND) { - MemoryPagingSource.append( - key = listMemberKey(listId), - value = result.toImmutableList(), - ) - } - - return MediatorResult.Success( - endOfPaginationReached = result.isEmpty(), - ) - } - }, - ) - - private fun userListsKey(userKey: MicroBlogKey) = "userLists_${userKey.id}" - - override suspend fun addMember( - listId: String, - userKey: MicroBlogKey, - ) { - tryRun { - service.addMember( - request = - AddMemberRequest( - variables = - AddMemberRequest.Variables( - listID = listId, - userID = userKey.id, - ), - ), - ) - val user = - service - .userById(userKey.id) - .body() - ?.data - ?.user - ?.result - ?.let { - when (it) { - is User -> it - is UserUnavailable -> null - } - }?.toDbUser(accountKey) - ?.render(accountKey = accountKey) ?: throw Exception("User not found") - MemoryPagingSource.updateWith( - key = listMemberKey(listId), - ) { - (listOf(user) + it) - .distinctBy { - it.key - }.toImmutableList() - } - val list = getListInfo(listId) - if (list?.id != null) { - MemCacheable.updateWith>( - key = userListsKey(userKey), - ) { - it - .plus(list) - .toImmutableList() - } - } - } - } - - override suspend fun removeMember( - listId: String, - userKey: MicroBlogKey, - ) { - tryRun { - service.removeMember( - request = - RemoveMemberRequest( - variables = - RemoveMemberRequest.Variables( - listID = listId, - userID = userKey.id, - ), - ), - ) - MemoryPagingSource.updateWith( - key = listMemberKey(listId), - ) { - it - .filter { user -> user.key.id != userKey.id } - .toImmutableList() - } - MemCacheable.updateWith>( - key = userListsKey(userKey), - ) { - it - .filter { list -> list.id != listId } - .toImmutableList() - } - } - } - - override fun listMemberCache(listId: String): Flow> = - MemoryPagingSource.getFlow(listMemberKey(listId)) - - override fun userLists(userKey: MicroBlogKey): MemCacheable> = - MemCacheable( - key = userListsKey(userKey), - ) { - service - .getListsMemberships( - userId = userKey.id, - ).body() - ?.lists - ?.mapNotNull { - it.render(accountKey = accountKey) - }.orEmpty() - .toImmutableList() - } - - override val supportedMetaData: ImmutableList - get() = persistentListOf(ListMetaDataType.TITLE, ListMetaDataType.DESCRIPTION) - override fun listTimeline(listId: String) = ListTimelineRemoteMediator( listId, 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 new file mode 100644 index 000000000..fe6b95286 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTListLoader.kt @@ -0,0 +1,140 @@ +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.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.CreateListRequest +import dev.dimension.flare.data.network.xqt.model.RemoveListRequest +import dev.dimension.flare.data.network.xqt.model.UpdateListRequest +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiList +import dev.dimension.flare.ui.model.mapper.list +import dev.dimension.flare.ui.model.mapper.render +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal class XQTListLoader( + private val service: XQTService, + private val accountKey: MicroBlogKey, +) : ListLoader { + override suspend fun load( + pageSize: Int, + request: PagingRequest, + ): PagingResult { + val cursor = (request as? PagingRequest.Append)?.nextKey + val response = + service + .getListsManagementPageTimeline( + variables = + buildString { + append("{\"count\":$pageSize") + if (cursor != null) { + append(",\"cursor\":\"${cursor}\"") + } + append("}") + }, + ).body() + ?.data + ?.viewer + ?.listManagementTimeline + ?.timeline + ?.instructions + + val nextCursor = response?.cursor() + + val result = + response + ?.list(accountKey = accountKey) + .orEmpty() + + return PagingResult( + data = result, + nextKey = nextCursor, + ) + } + + override suspend fun info(listId: String): UiList = + service + .getListByRestId( + variables = "{\"listId\":\"${listId}\"}", + ).body() + ?.data + ?.list + ?.render(accountKey = accountKey) + ?: throw Exception("List not found") + + override suspend fun create(metaData: ListMetaData): UiList { + val response = + service.createList( + request = + CreateListRequest( + variables = + CreateListRequest.Variables( + name = metaData.title, + description = metaData.description.orEmpty(), + isPrivate = false, + ), + ), + ) + val data = response.body()?.data?.list + if (data?.idStr != null) { + return UiList.List( + id = data.idStr, + title = metaData.title, + description = metaData.description, + creator = null, + avatar = null, + readonly = false, + ) + } else { + throw Exception("Failed to create list") + } + } + + override suspend fun update( + listId: String, + metaData: ListMetaData, + ): UiList { + service.updateList( + request = + UpdateListRequest( + variables = + UpdateListRequest.Variables( + listID = listId, + name = metaData.title, + description = metaData.description.orEmpty(), + isPrivate = false, + ), + ), + ) + return info(listId).let { + if (it is UiList.List) { + it.copy( + title = metaData.title, + description = metaData.description, + ) + } else { + it + } + } + } + + override suspend fun delete(listId: String) { + service.deleteList( + request = + RemoveListRequest( + variables = + RemoveListRequest.Variables( + listID = listId, + ), + ), + ) + } + + override val supportedMetaData: ImmutableList + get() = persistentListOf(ListMetaDataType.TITLE, ListMetaDataType.DESCRIPTION) +} 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 new file mode 100644 index 000000000..f35b3eb9a --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTListMemberLoader.kt @@ -0,0 +1,126 @@ +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.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.AddMemberRequest +import dev.dimension.flare.data.network.xqt.model.RemoveMemberRequest +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.mapper.render + +internal class XQTListMemberLoader( + private val service: XQTService, + private val accountKey: MicroBlogKey, +) : ListMemberLoader { + override suspend fun loadMembers( + pageSize: Int, + request: PagingRequest, + listId: String, + ): PagingResult { + val cursor = (request as? PagingRequest.Append)?.nextKey + val response = + service + .getListMembers( + variables = + buildString { + append("{\"listId\":\"${listId}\",\"count\":$pageSize") + if (cursor != null) { + append(",\"cursor\":\"${cursor}\"") + } + append("}") + }, + ).body() + ?.data + ?.list + ?.membersTimeline + ?.timeline + ?.instructions + + val nextCursor = response?.cursor() + + val result = + response?.users().orEmpty().map { + it.toDbUser(accountKey = accountKey) + } + + return PagingResult( + data = result, + nextKey = nextCursor, + ) + } + + override suspend fun addMember( + listId: String, + userKey: MicroBlogKey, + ): DbUser { + service.addMember( + request = + AddMemberRequest( + variables = + AddMemberRequest.Variables( + listID = listId, + userID = userKey.id, + ), + ), + ) + return service + .userById(userKey.id) + .body() + ?.data + ?.user + ?.result + ?.let { + when (it) { + is User -> it + is UserUnavailable -> null + } + }?.toDbUser(accountKey) + ?: throw Exception("User not found") + } + + override suspend fun removeMember( + listId: String, + userKey: MicroBlogKey, + ) { + service.removeMember( + request = + RemoveMemberRequest( + variables = + RemoveMemberRequest.Variables( + listID = listId, + userID = userKey.id, + ), + ), + ) + } + + override suspend fun loadUserLists( + pageSize: Int, + request: PagingRequest, + userKey: MicroBlogKey, + ): PagingResult { + // XQT getListsMemberships seems to return all lists or doesn't support pagination in the used endpoint/method signature easily? + // The original implementation didn't use pagination. + val result = + service + .getListsMemberships( + userId = userKey.id, + ).body() + ?.lists + ?.map { + it.render(accountKey = accountKey) + }.orEmpty() + + return PagingResult( + data = result, + ) + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/MisskeyOauthService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/MisskeyOauthService.kt index 7f39fd727..9402af7dd 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/MisskeyOauthService.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/MisskeyOauthService.kt @@ -36,6 +36,8 @@ private val defaultPermission = "read:page-likes", "write:gallery-likes", "read:gallery-likes", + "read:channels", + "write:channels", ) internal class MisskeyOauthService( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/MisskeyService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/MisskeyService.kt index bdd79c0ae..8788a238c 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/MisskeyService.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/MisskeyService.kt @@ -1,8 +1,10 @@ package dev.dimension.flare.data.network.misskey +import dev.dimension.flare.common.decodeJson import dev.dimension.flare.data.network.ktorfit import dev.dimension.flare.data.network.misskey.api.AccountApi import dev.dimension.flare.data.network.misskey.api.AntennasApi +import dev.dimension.flare.data.network.misskey.api.ChannelsApi import dev.dimension.flare.data.network.misskey.api.DriveApi import dev.dimension.flare.data.network.misskey.api.FollowingApi import dev.dimension.flare.data.network.misskey.api.HashtagsApi @@ -13,6 +15,7 @@ import dev.dimension.flare.data.network.misskey.api.ReactionsApi import dev.dimension.flare.data.network.misskey.api.UsersApi import dev.dimension.flare.data.network.misskey.api.createAccountApi import dev.dimension.flare.data.network.misskey.api.createAntennasApi +import dev.dimension.flare.data.network.misskey.api.createChannelsApi import dev.dimension.flare.data.network.misskey.api.createDriveApi import dev.dimension.flare.data.network.misskey.api.createFollowingApi import dev.dimension.flare.data.network.misskey.api.createHashtagsApi @@ -22,10 +25,16 @@ import dev.dimension.flare.data.network.misskey.api.createNotesApi import dev.dimension.flare.data.network.misskey.api.createReactionsApi import dev.dimension.flare.data.network.misskey.api.createUsersApi import dev.dimension.flare.data.network.misskey.api.model.DriveFile +import dev.dimension.flare.data.network.misskey.api.model.MisskeyException +import dev.dimension.flare.data.repository.RequireReLoginException +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformType import io.ktor.client.plugins.DefaultRequest +import io.ktor.client.plugins.HttpResponseValidator import io.ktor.client.request.forms.MultiPartFormDataContent import io.ktor.client.request.forms.formData import io.ktor.client.request.header +import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.Headers import io.ktor.http.HttpHeaders @@ -35,6 +44,7 @@ import kotlinx.coroutines.flow.firstOrNull private fun config( baseUrl: String, + accountKey: MicroBlogKey?, accessTokenFlow: Flow?, ) = ktorfit( baseUrl = baseUrl, @@ -49,22 +59,44 @@ private fun config( header(HttpHeaders.ContentType, ContentType.Application.Json) } } + HttpResponseValidator { + validateResponse { + runCatching { + it + .bodyAsText() + .decodeJson() + }.getOrNull() + ?.takeIf { it.error != null } + ?.let { + if (it.error?.code == "PERMISSION_DENIED" && accountKey != null) { + throw RequireReLoginException( + accountKey = accountKey, + platformType = PlatformType.Misskey, + ) + } else { + throw it + } + } + } + } }, ) internal class MisskeyService( baseUrl: String, - private val accessTokenFlow: Flow?, -) : UsersApi by config(baseUrl, accessTokenFlow).createUsersApi(), - MetaApi by config(baseUrl, accessTokenFlow).createMetaApi(), - NotesApi by config(baseUrl, accessTokenFlow).createNotesApi(), - AccountApi by config(baseUrl, accessTokenFlow).createAccountApi(), - DriveApi by config(baseUrl, accessTokenFlow).createDriveApi(), - ReactionsApi by config(baseUrl, accessTokenFlow).createReactionsApi(), - FollowingApi by config(baseUrl, accessTokenFlow).createFollowingApi(), - HashtagsApi by config(baseUrl, accessTokenFlow).createHashtagsApi(), - ListsApi by config(baseUrl, accessTokenFlow).createListsApi(), - AntennasApi by config(baseUrl, accessTokenFlow).createAntennasApi() { + accountKey: MicroBlogKey? = null, + private val accessTokenFlow: Flow? = null, +) : UsersApi by config(baseUrl, accountKey, accessTokenFlow).createUsersApi(), + MetaApi by config(baseUrl, accountKey, accessTokenFlow).createMetaApi(), + NotesApi by config(baseUrl, accountKey, accessTokenFlow).createNotesApi(), + AccountApi by config(baseUrl, accountKey, accessTokenFlow).createAccountApi(), + DriveApi by config(baseUrl, accountKey, accessTokenFlow).createDriveApi(), + ReactionsApi by config(baseUrl, accountKey, accessTokenFlow).createReactionsApi(), + FollowingApi by config(baseUrl, accountKey, accessTokenFlow).createFollowingApi(), + HashtagsApi by config(baseUrl, accountKey, accessTokenFlow).createHashtagsApi(), + ListsApi by config(baseUrl, accountKey, accessTokenFlow).createListsApi(), + AntennasApi by config(baseUrl, accountKey, accessTokenFlow).createAntennasApi(), + ChannelsApi by config(baseUrl, accountKey, accessTokenFlow).createChannelsApi() { suspend fun upload( data: ByteArray, name: String, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/ChannelsApi.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/ChannelsApi.kt index 55673254e..4e74fbe7a 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/ChannelsApi.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/ChannelsApi.kt @@ -1,9 +1,11 @@ package dev.dimension.flare.data.network.misskey.api import de.jensklingenberg.ktorfit.http.Body +import de.jensklingenberg.ktorfit.http.Header import de.jensklingenberg.ktorfit.http.POST import dev.dimension.flare.data.network.misskey.api.model.Channel import dev.dimension.flare.data.network.misskey.api.model.ChannelsCreateRequest +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.ChannelsFollowedRequest import dev.dimension.flare.data.network.misskey.api.model.ChannelsSearchRequest @@ -27,6 +29,7 @@ internal interface ChannelsApi { @POST("channels/create") suspend fun channelsCreate( @Body channelsCreateRequest: ChannelsCreateRequest, + @Header("Content-Type") contentType: kotlin.String = "application/json", ): Channel /** @@ -45,6 +48,7 @@ internal interface ChannelsApi { @POST("channels/favorite") suspend fun channelsFavorite( @Body channelsFollowRequest: ChannelsFollowRequest, + @Header("Content-Type") contentType: kotlin.String = "application/json", ): Unit /** @@ -58,11 +62,12 @@ internal interface ChannelsApi { * - 418: I'm Ai * - 500: Internal server error * - * @param body * @return [kotlin.collections.List] + * @param request * @return [kotlin.collections.List] */ @POST("channels/featured") suspend fun channelsFeatured( - @Body body: kotlin.Any, + @Body request: ChannelsFeaturedRequest, + @Header("Content-Type") contentType: kotlin.String = "application/json", ): kotlin.collections.List /** @@ -81,6 +86,7 @@ internal interface ChannelsApi { @POST("channels/follow") suspend fun channelsFollow( @Body channelsFollowRequest: ChannelsFollowRequest, + @Header("Content-Type") contentType: kotlin.String = "application/json", ): Unit /** @@ -99,6 +105,7 @@ internal interface ChannelsApi { @POST("channels/followed") suspend fun channelsFollowed( @Body channelsFollowedRequest: ChannelsFollowedRequest, + @Header("Content-Type") contentType: kotlin.String = "application/json", ): kotlin.collections.List /** @@ -116,7 +123,8 @@ internal interface ChannelsApi { */ @POST("channels/my-favorites") suspend fun channelsMyFavorites( - @Body body: kotlin.Any, + @Body channelsFollowedRequest: ChannelsFollowedRequest, + @Header("Content-Type") contentType: kotlin.String = "application/json", ): kotlin.collections.List /** @@ -135,6 +143,7 @@ internal interface ChannelsApi { @POST("channels/owned") suspend fun channelsOwned( @Body channelsFollowedRequest: ChannelsFollowedRequest, + @Header("Content-Type") contentType: kotlin.String = "application/json", ): kotlin.collections.List /** @@ -153,6 +162,7 @@ internal interface ChannelsApi { @POST("channels/search") suspend fun channelsSearch( @Body channelsSearchRequest: ChannelsSearchRequest, + @Header("Content-Type") contentType: kotlin.String = "application/json", ): kotlin.collections.List /** @@ -171,6 +181,7 @@ internal interface ChannelsApi { @POST("channels/show") suspend fun channelsShow( @Body channelsFollowRequest: ChannelsFollowRequest, + @Header("Content-Type") contentType: kotlin.String = "application/json", ): Channel /** @@ -189,6 +200,7 @@ internal interface ChannelsApi { @POST("channels/unfavorite") suspend fun channelsUnfavorite( @Body channelsFollowRequest: ChannelsFollowRequest, + @Header("Content-Type") contentType: kotlin.String = "application/json", ): Unit /** @@ -207,6 +219,7 @@ internal interface ChannelsApi { @POST("channels/unfollow") suspend fun channelsUnfollow( @Body channelsFollowRequest: ChannelsFollowRequest, + @Header("Content-Type") contentType: kotlin.String = "application/json", ): Unit /** @@ -225,5 +238,6 @@ internal interface ChannelsApi { @POST("channels/update") suspend fun channelsUpdate( @Body channelsUpdateRequest: ChannelsUpdateRequest, + @Header("Content-Type") contentType: kotlin.String = "application/json", ): Channel } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/NotesApi.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/NotesApi.kt index 8bba1675d..747c67494 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/NotesApi.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/NotesApi.kt @@ -1,6 +1,7 @@ package dev.dimension.flare.data.network.misskey.api import de.jensklingenberg.ktorfit.http.Body +import de.jensklingenberg.ktorfit.http.Header import de.jensklingenberg.ktorfit.http.POST import dev.dimension.flare.data.network.misskey.api.model.ChannelsTimelineRequest import dev.dimension.flare.data.network.misskey.api.model.IPinRequest @@ -42,6 +43,7 @@ internal interface NotesApi { */ @POST("channels/timeline") suspend fun channelsTimeline( + @Header("Content-Type") contentType: kotlin.String = "application/json", @Body channelsTimelineRequest: ChannelsTimelineRequest, ): kotlin.collections.List diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/model/Channel.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/model/Channel.kt index b65a5b0a0..e1f145bc6 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/model/Channel.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/model/Channel.kt @@ -29,9 +29,9 @@ internal data class Channel( @SerialName(value = "name") val name: kotlin.String, @SerialName(value = "description") val description: kotlin.String? = null, @SerialName(value = "bannerUrl") val bannerUrl: kotlin.String? = null, - @SerialName(value = "isArchived") val isArchived: kotlin.Boolean, - @SerialName(value = "notesCount") val notesCount: kotlin.Double, - @SerialName(value = "usersCount") val usersCount: kotlin.Double, + @SerialName(value = "isArchived") val isArchived: kotlin.Boolean? = null, + @SerialName(value = "notesCount") val notesCount: kotlin.Double? = null, + @SerialName(value = "usersCount") val usersCount: kotlin.Double? = null, @SerialName(value = "userId") val userId: kotlin.String? = null, @SerialName(value = "pinnedNoteIds") val pinnedNoteIds: kotlin.collections.List, @SerialName(value = "color") val color: kotlin.String, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/model/ChannelsFeaturedRequest.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/model/ChannelsFeaturedRequest.kt new file mode 100644 index 000000000..d68bb4515 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/model/ChannelsFeaturedRequest.kt @@ -0,0 +1,10 @@ +package dev.dimension.flare.data.network.misskey.api.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class ChannelsFeaturedRequest( + @SerialName(value = "limit") val limit: Int? = 10, + @SerialName(value = "allowPartial") val allowPartial: Boolean? = true, +) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/model/MisskeyException.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/model/MisskeyException.kt new file mode 100644 index 000000000..c3c63da52 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/model/MisskeyException.kt @@ -0,0 +1,16 @@ +package dev.dimension.flare.data.network.misskey.api.model + +import kotlinx.serialization.Serializable + +@Serializable +public data class MisskeyException( + val error: Error? = null, +) : Throwable(error?.message ?: "Unknown error") { + @Serializable + public data class Error( + val message: String? = null, + val code: String? = null, + val id: String? = null, + val kind: String? = null, + ) +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/model/Note.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/model/Note.kt index 182e2a582..f7c1d5487 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/model/Note.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/model/Note.kt @@ -50,7 +50,7 @@ internal data class Note( @SerialName(value = "tags") val tags: kotlin.collections.List? = null, @SerialName(value = "poll") val poll: Poll? = null, @SerialName(value = "channelId") val channelId: kotlin.String? = null, -// @SerialName(value = "channel") val channel: kotlin.collections.List? = null, + @SerialName(value = "channel") val channel: NoteChannelInner? = null, @SerialName(value = "localOnly") val localOnly: kotlin.Boolean? = null, @SerialName(value = "uri") val uri: kotlin.String? = null, @SerialName(value = "url") val url: kotlin.String? = null, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/model/NoteChannelInner.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/model/NoteChannelInner.kt index f9ca92a18..9ca9cde0e 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/model/NoteChannelInner.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/misskey/api/model/NoteChannelInner.kt @@ -25,4 +25,8 @@ import kotlinx.serialization.Serializable internal data class NoteChannelInner( @SerialName(value = "id") val id: kotlin.String? = null, @SerialName(value = "name") val name: kotlin.String? = null, + @SerialName(value = "color") val color: kotlin.String? = null, + @SerialName(value = "isSensitive") val isSensitive: kotlin.Boolean? = null, + @SerialName(value = "allowRenoteToExternal") val allowRenoteToExternal: kotlin.Boolean? = null, + @SerialName(value = "userId") val userId: kotlin.String? = null, ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nodeinfo/NodeInfoService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nodeinfo/NodeInfoService.kt index 2d165b659..20dcd9d30 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nodeinfo/NodeInfoService.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nodeinfo/NodeInfoService.kt @@ -144,7 +144,6 @@ internal data object NodeInfoService { tryRun { MisskeyService( "https://$hostCleaned/api/", - accessTokenFlow = null, ).meta(MetaRequest()).let { requireNotNull(it.name) // should be able to use as misskey diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/elonmusk114514/ElonMusk1145141919810.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/elonmusk114514/ElonMusk1145141919810.kt index a54d7410e..77b85ff99 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/elonmusk114514/ElonMusk1145141919810.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/elonmusk114514/ElonMusk1145141919810.kt @@ -6,11 +6,11 @@ import dev.whyoleg.cryptography.CryptographyProvider import dev.whyoleg.cryptography.algorithms.SHA256 import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText +import kotlinx.serialization.Serializable import kotlin.experimental.xor import kotlin.io.encoding.Base64 import kotlin.random.Random import kotlin.time.Clock -import kotlinx.serialization.Serializable internal object ElonMusk1145141919810 { @Serializable 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 8a12b3e31..617042f90 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 @@ -153,6 +153,12 @@ public data class LoginExpiredException( val platformType: PlatformType, ) : Exception("Login expired.") +@Immutable +public data class RequireReLoginException( + val accountKey: MicroBlogKey, + val platformType: PlatformType, +) : Exception("Login required.") + @Composable internal fun accountProvider( accountType: AccountType, 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 49795ed8c..807475aa9 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,33 @@ package dev.dimension.flare.ui.model +import kotlinx.serialization.Serializable + public data class ClickContext( val launcher: UriLauncher, ) + +@Serializable +internal sealed interface ClickEvent { + @Serializable + data object Noop : ClickEvent + + @Serializable + data class Deeplink( + val url: String, + ) : ClickEvent +} + +internal val ClickEvent.onClicked: ClickContext.() -> Unit + get() { + when (this) { + is ClickEvent.Deeplink -> { + return { + launcher.launch(url) + } + } + + is ClickEvent.Noop -> { + return {} + } + } + } 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 67b733ee4..a05352d7c 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 @@ -9,7 +9,7 @@ import kotlinx.collections.immutable.ImmutableList @Immutable public data class UiDMRoom internal constructor( val key: MicroBlogKey, - val users: ImmutableList, + val users: ImmutableList, val lastMessage: UiDMItem?, val unreadCount: Long, ) { @@ -33,7 +33,7 @@ public data class UiDMRoom internal constructor( @Immutable public data class UiDMItem internal constructor( val key: MicroBlogKey, - val user: UiUserV2, + val user: UiProfile, val content: Message, val timestamp: UiDateTime, val isFromMe: Boolean, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiList.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiList.kt index 3eecb5a4d..3820cb244 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiList.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiList.kt @@ -1,24 +1,61 @@ package dev.dimension.flare.ui.model import androidx.compose.runtime.Immutable -import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.render.UiRichText +import kotlinx.serialization.Serializable +@Serializable @Immutable -public data class UiList internal constructor( - val id: String, - val title: String, - val description: String? = null, - val avatar: String? = null, - val creator: UiUserV2? = null, - val likedCount: UiNumber = UiNumber(0), - val liked: Boolean = false, - val platformType: PlatformType, - val type: Type = Type.List, - val readonly: Boolean = false, -) { - public enum class Type { - Feed, - List, - Antenna, - } +public sealed class UiList { +// public abstract val id: String + public abstract val id: String + public abstract val title: String + public abstract val readonly: Boolean + + @Serializable + @Immutable + public data class List( + override val id: String, + override val title: String, + val description: String? = null, + val avatar: String? = null, + val creator: UiProfile? = null, + override val readonly: Boolean = false, + ) : UiList() + + @Serializable + @Immutable + public data class Feed( + override val id: String, + override val title: String, + val description: String? = null, + val avatar: String? = null, + val creator: UiProfile? = null, + val likedCount: UiNumber = UiNumber(0), + val liked: Boolean = false, + override val readonly: Boolean = false, + ) : UiList() + + @Serializable + @Immutable + public data class Antenna( + override val id: String, + override val title: String, + override val readonly: Boolean = false, + ) : UiList() + + @Serializable + @Immutable + public data class Channel( + override val id: String, + override val title: String, + val isArchived: Boolean, + val notesCount: Double, + val usersCount: Double, + val description: UiRichText? = null, + val banner: String? = null, + val isFollowing: Boolean? = null, + val isFavorited: Boolean? = null, + override val readonly: Boolean = false, + ) : UiList() } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiNumber.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiNumber.kt index 7fd60b850..c0257990c 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiNumber.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiNumber.kt @@ -2,10 +2,14 @@ package dev.dimension.flare.ui.model import androidx.compose.runtime.Immutable import dev.dimension.flare.ui.humanizer.Formatter.humanize +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +@Serializable @Immutable public data class UiNumber internal constructor( public val value: Long, ) { + @Transient public val humanized: String = value.takeIf { it > 0 }?.humanize().orEmpty() } 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 8d9177753..931196e4a 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 @@ -9,8 +9,8 @@ public data class UiPodcast( val title: String, val playbackUrl: String?, val ended: Boolean, - val creator: UiUserV2, - val hosts: ImmutableList, - val speakers: ImmutableList, - val listeners: ImmutableList, + val creator: UiProfile, + val hosts: ImmutableList, + val speakers: ImmutableList, + val listeners: ImmutableList, ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiProfile.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiProfile.kt index 514566068..f4f6a81bb 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiProfile.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiProfile.kt @@ -2,32 +2,33 @@ package dev.dimension.flare.ui.model import androidx.compose.runtime.Immutable import com.fleeksoft.ksoup.nodes.Element +import dev.dimension.flare.common.SerializableImmutableList +import dev.dimension.flare.common.SerializableImmutableMap import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType import dev.dimension.flare.ui.humanizer.Formatter.humanize -import dev.dimension.flare.ui.humanizer.humanize import dev.dimension.flare.ui.render.UiRichText import dev.dimension.flare.ui.render.toUi -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentListOf +import kotlinx.serialization.Serializable +@Serializable @Immutable public data class UiProfile internal constructor( - override val key: MicroBlogKey, - override val handle: String, - override val avatar: String, + val key: MicroBlogKey, + val handle: String, + val avatar: String, private val nameInternal: UiRichText, - override val platformType: PlatformType, - override val onClicked: ClickContext.() -> Unit, + val platformType: PlatformType, + private val clickEvent: ClickEvent, public val banner: String?, public val description: UiRichText?, public val matrices: Matrices, - public val mark: ImmutableList, + public val mark: SerializableImmutableList, public val bottomContent: BottomContent?, -) : UiUserV2 { +) { // If name is blank, use handle without @ as display name - override val name: UiRichText by lazy { + val name: UiRichText by lazy { if (nameInternal.raw.isEmpty() || nameInternal.raw.isBlank()) { Element("span") .apply { @@ -38,6 +39,11 @@ public data class UiProfile internal constructor( } } + val onClicked: ClickContext.() -> Unit by lazy { + clickEvent.onClicked + } + + @Serializable @Immutable public data class Matrices internal constructor( val fansCount: Long, @@ -70,13 +76,16 @@ public data class UiProfile internal constructor( } } + @Serializable public sealed interface BottomContent { + @Serializable public data class Fields internal constructor( - val fields: ImmutableMap, + val fields: SerializableImmutableMap, ) : BottomContent + @Serializable public data class Iconify internal constructor( - val items: ImmutableMap, + val items: SerializableImmutableMap, ) : BottomContent { public enum class Icon { Location, @@ -86,6 +95,7 @@ public data class UiProfile internal constructor( } } + @Serializable public enum class Mark { Verified, Cat, @@ -101,7 +111,7 @@ public fun createSampleUser(): UiProfile = avatar = "https://example.com/avatar.jpg", nameInternal = Element("span").toUi(), platformType = PlatformType.Mastodon, - onClicked = { /* Handle click */ }, + clickEvent = ClickEvent.Noop, banner = "https://example.com/banner.jpg", description = null, matrices = diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiStatus.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiStatus.kt index bbffac9fc..0a128943e 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiStatus.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiStatus.kt @@ -89,7 +89,7 @@ private fun String.trimUrl(): String = } } -public fun createSampleStatus(user: UiUserV2): UiTimeline = +public fun createSampleStatus(user: UiProfile): UiTimeline = UiTimeline( topMessage = null, content = diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimeline.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimeline.kt index aa1782b3f..5075bd479 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimeline.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimeline.kt @@ -103,7 +103,7 @@ public data class UiTimeline internal constructor( val images: ImmutableList, val sensitive: Boolean, val contentWarning: UiRichText?, - val user: UiUserV2?, + val user: UiProfile?, val quote: ImmutableList, val content: UiRichText, val actions: ImmutableList, @@ -139,7 +139,14 @@ public data class UiTimeline internal constructor( @Immutable public data class Reaction internal constructor( val emojiReactions: ImmutableList, + val channel: SourceChannel? = null, ) : BottomContent() { + @Immutable + public data class SourceChannel( + val id: String, + val name: String, + ) + @Immutable public data class EmojiReaction internal constructor( val name: String, @@ -169,6 +176,7 @@ public data class UiTimeline internal constructor( Home, Followers, Specified, + Channel, } } } @@ -184,7 +192,7 @@ public data class UiTimeline internal constructor( @Immutable public data class User internal constructor( - val value: UiUserV2, + val value: UiProfile, val button: ImmutableList