diff --git a/app/src/main/java/dev/dimension/flare/di/AndroidModule.kt b/app/src/main/java/dev/dimension/flare/di/AndroidModule.kt index c2e535d94..a0beab569 100644 --- a/app/src/main/java/dev/dimension/flare/di/AndroidModule.kt +++ b/app/src/main/java/dev/dimension/flare/di/AndroidModule.kt @@ -8,8 +8,6 @@ import dev.dimension.flare.ui.component.status.mastodon.DefaultMastodonStatusEve import dev.dimension.flare.ui.component.status.mastodon.MastodonStatusEvent import dev.dimension.flare.ui.component.status.misskey.DefaultMisskeyStatusEvent import dev.dimension.flare.ui.component.status.misskey.MisskeyStatusEvent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import org.koin.core.module.dsl.binds import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.withOptions @@ -26,7 +24,6 @@ val androidModule = singleOf(::DefaultMastodonStatusEvent) withOptions { binds(listOf(MastodonStatusEvent::class)) } - single { CoroutineScope(Dispatchers.IO) } singleOf(::ComposeUseCase) singleOf(::StatusEvent) } diff --git a/app/src/main/java/dev/dimension/flare/ui/component/status/bluesky/BlueskyStatusComponent.kt b/app/src/main/java/dev/dimension/flare/ui/component/status/bluesky/BlueskyStatusComponent.kt index 918da6d6b..e70b15902 100644 --- a/app/src/main/java/dev/dimension/flare/ui/component/status/bluesky/BlueskyStatusComponent.kt +++ b/app/src/main/java/dev/dimension/flare/ui/component/status/bluesky/BlueskyStatusComponent.kt @@ -12,11 +12,13 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Reply +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.FormatQuote import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material.icons.filled.Reply +import androidx.compose.material.icons.filled.Report import androidx.compose.material.icons.filled.SyncAlt import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -38,6 +40,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import dev.dimension.flare.R import dev.dimension.flare.common.deeplink +import dev.dimension.flare.data.repository.AccountRepository import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.component.HtmlText2 import dev.dimension.flare.ui.component.status.CommonStatusHeaderComponent @@ -45,12 +48,16 @@ import dev.dimension.flare.ui.component.status.StatusActionButton import dev.dimension.flare.ui.component.status.StatusMediaComponent import dev.dimension.flare.ui.component.status.StatusRetweetHeaderComponent import dev.dimension.flare.ui.component.status.UiStatusQuoted +import dev.dimension.flare.ui.model.UiAccount import dev.dimension.flare.ui.model.UiMedia import dev.dimension.flare.ui.model.UiStatus import dev.dimension.flare.ui.model.contentDirection +import dev.dimension.flare.ui.screen.destinations.BlueskyReportStatusRouteDestination +import dev.dimension.flare.ui.screen.destinations.DeleteStatusConfirmRouteDestination import dev.dimension.flare.ui.screen.destinations.ProfileRouteDestination import dev.dimension.flare.ui.theme.MediumAlpha import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch @Composable internal fun BlueskyStatusComponent( @@ -151,6 +158,9 @@ private fun StatusFooterComponent( var showRenoteMenu by remember { mutableStateOf(false) } + var showMoreMenu by remember { + mutableStateOf(false) + } Row( modifier = modifier @@ -253,7 +263,51 @@ private fun StatusFooterComponent( icon = Icons.Default.MoreHoriz, text = null, onClicked = { - event.onMoreClick(data) + showMoreMenu = true + }, + content = { + DropdownMenu( + expanded = showMoreMenu, + onDismissRequest = { showMoreMenu = false }, + ) { + if (!data.isFromMe) { + DropdownMenuItem( + text = { + Text( + text = stringResource(id = R.string.blusky_item_action_report), + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Report, + contentDescription = null, + ) + }, + onClick = { + showMoreMenu = false + event.onReportClick(data) + }, + ) + } else { + DropdownMenuItem( + text = { + Text( + text = stringResource(id = R.string.blusky_item_action_delete), + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + ) + }, + onClick = { + showMoreMenu = false + event.onDeleteClick(data) + }, + ) + } + } }, ) } @@ -275,11 +329,14 @@ internal interface BlueskyStatusEvent { fun onLikeClick(data: UiStatus.Bluesky) - fun onMoreClick(data: UiStatus.Bluesky) + fun onReportClick(data: UiStatus.Bluesky) + + fun onDeleteClick(data: UiStatus.Bluesky) } internal class DefaultBlueskyStatusEvent( private val context: Context, + private val accountRepository: AccountRepository, private val scope: CoroutineScope, ) : BlueskyStatusEvent { override fun onStatusClick(data: UiStatus.Bluesky) { @@ -331,12 +388,45 @@ internal class DefaultBlueskyStatusEvent( } override fun onReblogClick(data: UiStatus.Bluesky) { + scope.launch { + val account = + accountRepository.get(data.accountKey) as? UiAccount.Bluesky ?: return@launch + account.dataSource.reblog(data) + } } override fun onLikeClick(data: UiStatus.Bluesky) { + scope.launch { + val account = + accountRepository.get(data.accountKey) as? UiAccount.Bluesky ?: return@launch + account.dataSource.like(data) + } + } + + override fun onReportClick(data: UiStatus.Bluesky) { + val intent = + Intent( + Intent.ACTION_VIEW, + Uri.parse( + BlueskyReportStatusRouteDestination(data.statusKey) + .deeplink(), + ), + ) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) } - override fun onMoreClick(data: UiStatus.Bluesky) { + override fun onDeleteClick(data: UiStatus.Bluesky) { + val intent = + Intent( + Intent.ACTION_VIEW, + Uri.parse( + DeleteStatusConfirmRouteDestination(data.statusKey) + .deeplink(), + ), + ) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) } override fun onQuoteClick(data: UiStatus.Bluesky) { diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/status/action/BlueskyReportStatusDialog.kt b/app/src/main/java/dev/dimension/flare/ui/screen/status/action/BlueskyReportStatusDialog.kt new file mode 100644 index 000000000..572566a5d --- /dev/null +++ b/app/src/main/java/dev/dimension/flare/ui/screen/status/action/BlueskyReportStatusDialog.kt @@ -0,0 +1,152 @@ +package dev.dimension.flare.ui.screen.status.action + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ListItem +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.ramcosta.composedestinations.annotation.DeepLink +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.FULL_ROUTE_PLACEHOLDER +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import dev.dimension.flare.R +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.molecule.producePresenter +import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.presenter.status.action.BlueskyReportStatusPresenter +import dev.dimension.flare.ui.presenter.status.action.BlueskyReportStatusState + +@Composable +@Destination( + deepLinks = [ + DeepLink( + uriPattern = "flare://$FULL_ROUTE_PLACEHOLDER", + ), + ], +) +fun BlueskyReportStatusRoute( + navigator: DestinationsNavigator, + statusKey: MicroBlogKey, +) { + BlueskyReportStatusDialog( + statusKey = statusKey, + onBack = { + navigator.navigateUp() + }, + ) +} + +@Composable +internal fun BlueskyReportStatusDialog( + statusKey: MicroBlogKey, + onBack: () -> Unit, +) { + val state by producePresenter(key = statusKey.toString()) { + blueskyReportStatusPresenter(statusKey) + } + + AlertDialog( + onDismissRequest = onBack, + confirmButton = { + TextButton( + enabled = state.reason != null, + onClick = { + state.status.onSuccess { status -> + state.reason?.let { + state.report(it, status) + onBack.invoke() + } + } + }, + ) { + Text(text = stringResource(id = R.string.confirm)) + } + }, + dismissButton = { + TextButton(onClick = onBack) { + Text(text = stringResource(id = R.string.cancel)) + } + }, + title = { + Text(text = stringResource(id = R.string.report_title)) + }, + text = { + Column { + Text(text = stringResource(id = R.string.report_description)) + state.allReasons.forEach { + val interactionSource = + remember(it) { + MutableInteractionSource() + } + ListItem( + modifier = + Modifier.clickable( + interactionSource = interactionSource, + indication = null, + onClick = { + state.selectReason(it) + }, + ), + headlineContent = { + Text(text = stringResource(id = it.stringRes)) + }, + supportingContent = { + Text(text = stringResource(id = it.descriptionRes)) + }, + leadingContent = { + RadioButton( + selected = state.reason == it, + interactionSource = interactionSource, + onClick = { + state.selectReason(it) + }, + ) + }, + ) + } + } + }, + ) +} + +@Composable +private fun blueskyReportStatusPresenter(statusKey: MicroBlogKey) = + run { + val state = + remember(statusKey) { + BlueskyReportStatusPresenter(statusKey) + }.invoke() + + object : BlueskyReportStatusState by state { + } + } + +private val BlueskyReportStatusState.ReportReason.stringRes: Int + get() = + when (this) { + BlueskyReportStatusState.ReportReason.Spam -> R.string.report_reason_spam_title + BlueskyReportStatusState.ReportReason.Violation -> R.string.report_reason_violation_title + BlueskyReportStatusState.ReportReason.Misleading -> R.string.report_reason_misleading_title + BlueskyReportStatusState.ReportReason.Sexual -> R.string.report_reason_sexual_title + BlueskyReportStatusState.ReportReason.Rude -> R.string.report_reason_rude_title + BlueskyReportStatusState.ReportReason.Other -> R.string.report_reason_other_title + } + +private val BlueskyReportStatusState.ReportReason.descriptionRes: Int + get() = + when (this) { + BlueskyReportStatusState.ReportReason.Spam -> R.string.report_reason_spam_description + BlueskyReportStatusState.ReportReason.Violation -> R.string.report_reason_violation_description + BlueskyReportStatusState.ReportReason.Misleading -> R.string.report_reason_misleading_description + BlueskyReportStatusState.ReportReason.Sexual -> R.string.report_reason_sexual_description + BlueskyReportStatusState.ReportReason.Rude -> R.string.report_reason_rude_description + BlueskyReportStatusState.ReportReason.Other -> R.string.report_reason_other_description + } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/status/action/DeleteStatusConfirmDialog.kt b/app/src/main/java/dev/dimension/flare/ui/screen/status/action/DeleteStatusConfirmDialog.kt new file mode 100644 index 000000000..6a1527f9f --- /dev/null +++ b/app/src/main/java/dev/dimension/flare/ui/screen/status/action/DeleteStatusConfirmDialog.kt @@ -0,0 +1,85 @@ +package dev.dimension.flare.ui.screen.status.action + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import com.ramcosta.composedestinations.annotation.DeepLink +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.FULL_ROUTE_PLACEHOLDER +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import dev.dimension.flare.R +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.molecule.producePresenter +import dev.dimension.flare.ui.presenter.status.action.DeleteStatusPresenter +import dev.dimension.flare.ui.presenter.status.action.DeleteStatusState + +@Composable +@Destination( + deepLinks = [ + DeepLink( + uriPattern = "flare://$FULL_ROUTE_PLACEHOLDER", + ), + ], +) +fun DeleteStatusConfirmRoute( + navigator: DestinationsNavigator, + statusKey: MicroBlogKey, +) { + DeleteStatusConfirmDialog( + statusKey = statusKey, + onBack = { + navigator.navigateUp() + }, + ) +} + +@Composable +fun DeleteStatusConfirmDialog( + statusKey: MicroBlogKey, + onBack: () -> Unit, +) { + val state by producePresenter(key = statusKey.toString()) { + deleteStatusConfirmPresenter(statusKey) + } + + AlertDialog( + onDismissRequest = onBack, + confirmButton = { + TextButton( + onClick = { + state.delete() + onBack.invoke() + }, + ) { + Text(text = stringResource(id = R.string.confirm)) + } + }, + dismissButton = { + TextButton(onClick = onBack) { + Text(text = stringResource(id = R.string.cancel)) + } + }, + title = { + Text(text = stringResource(id = R.string.delete_status_title)) + }, + text = { + Text(text = stringResource(id = R.string.delete_status_message)) + }, + ) +} + +@Composable +fun deleteStatusConfirmPresenter(statusKey: MicroBlogKey) = + run { + val state = + remember(key1 = statusKey) { + DeleteStatusPresenter(statusKey) + }.invoke() + + object : DeleteStatusState by state { + } + } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 78fb679fa..1f8662db7 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2,6 +2,8 @@ 登录 返回 + 确认 + 取消 首页 通知 @@ -131,4 +133,27 @@ 转发 引用 + 举报 + 删除 + + 举报 + 这篇帖子有什么问题? + + 垃圾信息 + 违法违规 + 误导 + 不良性内容 + 反社会行为 + 其他 + + 过多的提及或回复 + 明显违反法律或服务条款 + 这篇帖子具有误导性 + 未标明的裸露或色情内容 + 骚扰、恶作剧或不宽容行为 + 这些选项中未包含的问题 + + 删除 + 您确定要删除此内容吗? + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0d9c23507..5901382d8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,6 +3,8 @@ Login Navigate back + Confirm + Cancel Home Notifications @@ -134,4 +136,26 @@ Repost Quote + Report + Delete + + Report + What\'s the issue with this post? + + Span + Illegal and Urgent + Misleading + Unwanted Sexual Content + Anti-Social Behavior + Other + + Excessive mentions or replies + Glaring violations of law or terms of service + This post is misleading + Nudity or pornography not labeled as such + Harassment, trolling, or intolerance + An issue not included in these options + + Delete + Are you sure you want to delete this? \ No newline at end of file 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 12ae05868..3a10a7e50 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 @@ -196,7 +196,7 @@ fun FeedViewPost.toDbStatus(accountKey: MicroBlogKey): DbStatus { ), platform_type = PlatformType.Bluesky, user_key = user.user_key, - content = StatusContent.BlueskyReason(data, post), + content = StatusContent.Bluesky(post, data), account_key = accountKey, id = 0, ) @@ -219,7 +219,7 @@ private fun PostView.toDbStatus(accountKey: MicroBlogKey): DbStatus { host = user.user_key.host, ), platform_type = PlatformType.Bluesky, - content = StatusContent.Bluesky(this), + content = StatusContent.Bluesky(this, null), user_key = user.user_key, account_key = accountKey, id = 0, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/StatusContent.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/StatusContent.kt index cc25ec557..4a18aaeab 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/StatusContent.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/model/StatusContent.kt @@ -33,11 +33,11 @@ sealed interface StatusContent { @Serializable @SerialName("bluesky") - data class Bluesky(val data: PostView) : StatusContent + data class Bluesky(val data: PostView, val reason: FeedViewPostReasonUnion?) : StatusContent - @Serializable - @SerialName("bluesky-reason") - data class BlueskyReason(val reason: FeedViewPostReasonUnion, val data: PostView) : StatusContent +// @Serializable +// @SerialName("bluesky-reason") +// data class BlueskyReason(val reason: FeedViewPostReasonUnion, val data: PostView) : StatusContent @Serializable @SerialName("bluesky-notification") diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/MicroblogDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/MicroblogDataSource.kt index a39f28b2a..2ca52cb75 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/MicroblogDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/MicroblogDataSource.kt @@ -62,6 +62,8 @@ interface MicroblogDataSource { data: ComposeData, progress: (ComposeProgress) -> Unit, ) + + suspend fun deleteStatus(statusKey: MicroBlogKey) } data class ComposeProgress( 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 001ac4931..a6ad82d70 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 @@ -4,9 +4,15 @@ import androidx.paging.ExperimentalPagingApi import androidx.paging.PagingData import app.bsky.actor.GetProfileQueryParams import app.bsky.feed.GetPostsQueryParams +import app.bsky.feed.ViewerState import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.coroutines.mapToOneNotNull +import com.atproto.moderation.CreateReportRequest +import com.atproto.moderation.CreateReportRequestSubjectUnion +import com.atproto.moderation.Token import com.atproto.repo.CreateRecordRequest +import com.atproto.repo.DeleteRecordRequest +import com.atproto.repo.StrongRef import dev.dimension.flare.common.CacheData import dev.dimension.flare.common.Cacheable import dev.dimension.flare.common.FileItem @@ -16,6 +22,8 @@ import dev.dimension.flare.common.jsonObjectOrNull import dev.dimension.flare.data.database.app.AppDatabase import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.mapper.toDbUser +import dev.dimension.flare.data.database.cache.model.StatusContent +import dev.dimension.flare.data.database.cache.model.updateStatusUseCase import dev.dimension.flare.data.datasource.ComposeData import dev.dimension.flare.data.datasource.ComposeProgress import dev.dimension.flare.data.datasource.MicroblogDataSource @@ -31,6 +39,7 @@ import dev.dimension.flare.ui.model.UiUser import dev.dimension.flare.ui.model.flatMap import dev.dimension.flare.ui.model.mapper.toUi import dev.dimension.flare.ui.model.toUi +import dev.dimension.flare.ui.presenter.status.action.BlueskyReportStatusState import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -47,6 +56,7 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import sh.christian.ozone.api.AtIdentifier import sh.christian.ozone.api.AtUri +import sh.christian.ozone.api.Cid import sh.christian.ozone.api.Nsid @OptIn(ExperimentalPagingApi::class) @@ -350,4 +360,276 @@ class BlueskyDataSource( ), ) } + + suspend fun report( + data: UiStatus.Bluesky, + reason: BlueskyReportStatusState.ReportReason, + ) { + runCatching { + val service = account.getService(appDatabase) + service.createReport( + CreateReportRequest( + reasonType = + when (reason) { + BlueskyReportStatusState.ReportReason.Spam -> Token.REASON_SPAM + BlueskyReportStatusState.ReportReason.Violation -> Token.REASON_VIOLATION + BlueskyReportStatusState.ReportReason.Misleading -> Token.REASON_MISLEADING + BlueskyReportStatusState.ReportReason.Sexual -> Token.REASON_SEXUAL + BlueskyReportStatusState.ReportReason.Rude -> Token.REASON_RUDE + BlueskyReportStatusState.ReportReason.Other -> Token.REASON_OTHER + }, + subject = + CreateReportRequestSubjectUnion.RepoStrongRef( + value = + StrongRef( + uri = AtUri(data.uri), + cid = Cid(data.cid), + ), + ), + ), + ) + } + } + + suspend fun reblog(data: UiStatus.Bluesky) { + updateStatusUseCase( + statusKey = data.statusKey, + accountKey = account.accountKey, + cacheDatabase = database, + ) { content -> + val uri = + if (data.reaction.reposted) { + null + } else { + AtUri("") + } + val count = + if (data.reaction.reposted) { + (content.data.repostCount ?: 0) - 1 + } else { + (content.data.repostCount ?: 0) + 1 + }.coerceAtLeast(0) + content.copy( + data = + content.data.copy( + viewer = + content.data.viewer?.copy( + repost = uri, + ) ?: ViewerState( + repost = uri, + ), + repostCount = count, + ), + ) + } + runCatching { + val service = account.getService(appDatabase) + if (data.reaction.reposted && data.reaction.repostUri != null) { + service.deleteRecord( + DeleteRecordRequest( + repo = AtIdentifier(account.accountKey.id), + collection = Nsid("app.bsky.feed.repost"), + rkey = data.reaction.repostUri.substringAfterLast('/'), + ), + ) + } else { + val result = + service.createRecord( + CreateRecordRequest( + repo = AtIdentifier(account.accountKey.id), + collection = Nsid("app.bsky.feed.repost"), + record = + buildJsonObject { + put("\$type", "app.bsky.feed.repost") + put("createdAt", Clock.System.now().toString()) + put( + "subject", + buildJsonObject { + put("cid", data.cid) + put("uri", data.uri) + }, + ) + }, + ), + ).requireResponse() + updateStatusUseCase( + statusKey = data.statusKey, + accountKey = account.accountKey, + cacheDatabase = database, + ) { content -> + content.copy( + data = + content.data.copy( + viewer = + content.data.viewer?.copy( + repost = AtUri(result.uri.atUri), + ) ?: ViewerState( + repost = AtUri(result.uri.atUri), + ), + ), + ) + } + } + }.onFailure { + updateStatusUseCase( + statusKey = data.statusKey, + accountKey = account.accountKey, + cacheDatabase = database, + ) { content -> + val uri = + if (data.reaction.reposted) { + AtUri(data.reaction.repostUri ?: "") + } else { + null + } + val count = + if (data.reaction.reposted) { + (content.data.repostCount ?: 0) + 1 + } else { + (content.data.repostCount ?: 0) - 1 + }.coerceAtLeast(0) + content.copy( + data = + content.data.copy( + viewer = + content.data.viewer?.copy( + repost = uri, + ) ?: ViewerState( + repost = uri, + ), + repostCount = count, + ), + ) + } + } + } + + suspend fun like(data: UiStatus.Bluesky) { + updateStatusUseCase( + statusKey = data.statusKey, + accountKey = account.accountKey, + cacheDatabase = database, + ) { content -> + val uri = + if (data.reaction.liked) { + null + } else { + AtUri("") + } + val count = + if (data.reaction.liked) { + (content.data.likeCount ?: 0) - 1 + } else { + (content.data.likeCount ?: 0) + 1 + }.coerceAtLeast(0) + content.copy( + data = + content.data.copy( + viewer = + content.data.viewer?.copy( + like = uri, + ) ?: ViewerState( + like = uri, + ), + likeCount = count, + ), + ) + } + runCatching { + val service = account.getService(appDatabase) + if (data.reaction.liked && data.reaction.likedUri != null) { + service.deleteRecord( + DeleteRecordRequest( + repo = AtIdentifier(account.accountKey.id), + collection = Nsid("app.bsky.feed.like"), + rkey = data.reaction.likedUri.substringAfterLast('/'), + ), + ) + } else { + val result = + service.createRecord( + CreateRecordRequest( + repo = AtIdentifier(account.accountKey.id), + collection = Nsid("app.bsky.feed.like"), + record = + buildJsonObject { + put("\$type", "app.bsky.feed.like") + put("createdAt", Clock.System.now().toString()) + put( + "subject", + buildJsonObject { + put("cid", data.cid) + put("uri", data.uri) + }, + ) + }, + ), + ).requireResponse() + updateStatusUseCase( + statusKey = data.statusKey, + accountKey = account.accountKey, + cacheDatabase = database, + ) { content -> + content.copy( + data = + content.data.copy( + viewer = + content.data.viewer?.copy( + like = AtUri(result.uri.atUri), + ) ?: ViewerState( + like = AtUri(result.uri.atUri), + ), + ), + ) + } + } + }.onFailure { + updateStatusUseCase( + statusKey = data.statusKey, + accountKey = account.accountKey, + cacheDatabase = database, + ) { content -> + val uri = + if (data.reaction.liked) { + AtUri(data.reaction.likedUri ?: "") + } else { + null + } + val count = + if (data.reaction.liked) { + (content.data.likeCount ?: 0) + 1 + } else { + (content.data.likeCount ?: 0) - 1 + }.coerceAtLeast(0) + content.copy( + data = + content.data.copy( + viewer = + content.data.viewer?.copy( + like = uri, + ) ?: ViewerState( + like = uri, + ), + likeCount = count, + ), + ) + } + } + } + + override suspend fun deleteStatus(statusKey: MicroBlogKey) { + runCatching { + val service = account.getService(appDatabase) + service.deleteRecord( + DeleteRecordRequest( + repo = AtIdentifier(account.accountKey.id), + collection = Nsid("app.bsky.feed.post"), + rkey = statusKey.id.substringAfterLast('/'), + ), + ) + // delete status from cache + database.dbStatusQueries.delete(status_key = statusKey, account_key = account.accountKey) + database.dbPagingTimelineQueries.deleteStatus(account_key = account.accountKey, status_key = statusKey) + } + } } 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 a334741ba..a9bb93047 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 @@ -404,4 +404,13 @@ class MastodonDataSource( ) } } + + override suspend fun deleteStatus(statusKey: MicroBlogKey) { + runCatching { + service.delete(statusKey.id) + // delete status from cache + database.dbStatusQueries.delete(status_key = statusKey, account_key = account.accountKey) + database.dbPagingTimelineQueries.deleteStatus(account_key = account.accountKey, status_key = statusKey) + } + } } 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 196353a40..3e73fce38 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 @@ -15,6 +15,7 @@ import dev.dimension.flare.data.datasource.ComposeProgress import dev.dimension.flare.data.datasource.MicroblogDataSource import dev.dimension.flare.data.datasource.NotificationFilter import dev.dimension.flare.data.datasource.timelinePager +import dev.dimension.flare.data.network.misskey.api.model.IPinRequest import dev.dimension.flare.data.network.misskey.api.model.NotesChildrenRequest import dev.dimension.flare.data.network.misskey.api.model.NotesCreateRequest import dev.dimension.flare.data.network.misskey.api.model.NotesCreateRequestPoll @@ -323,4 +324,18 @@ class MisskeyDataSource( ), ) } + + override suspend fun deleteStatus(statusKey: MicroBlogKey) { + runCatching { + service.notesDelete( + IPinRequest( + noteId = statusKey.id, + ), + ) + + // delete status from cache + database.dbStatusQueries.delete(status_key = statusKey, account_key = account.accountKey) + database.dbPagingTimelineQueries.deleteStatus(account_key = account.accountKey, status_key = statusKey) + } + } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt index 03adde034..ecce3c850 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt @@ -5,6 +5,9 @@ import dev.dimension.flare.data.database.provideCacheDatabase import dev.dimension.flare.data.database.provideVersionDatabase import dev.dimension.flare.data.repository.AccountRepository import dev.dimension.flare.data.repository.ApplicationRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import org.koin.core.module.dsl.singleOf import org.koin.dsl.module @@ -15,4 +18,5 @@ val commonModule = single { provideAppDatabase(get(), get()) } single { provideCacheDatabase(get(), get()) } singleOf(::ApplicationRepository) + single { CoroutineScope(Dispatchers.IO) } } 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 7d669c1e7..de9bf9920 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 @@ -304,6 +304,8 @@ sealed class UiStatus { val card: UiCard?, val matrices: Matrices, val reaction: Reaction, + val cid: String, + val uri: String, ) : UiStatus() { val humanizedTime by lazy { indexedAt.humanize() @@ -312,6 +314,10 @@ sealed class UiStatus { misskeyParser.parse(content).toHtml(accountKey.host) } + val isFromMe by lazy { + user.userKey == accountKey + } + data class Matrices( val replyCount: Long, val likeCount: Long, @@ -323,9 +329,17 @@ sealed class UiStatus { } data class Reaction( - val liked: Boolean, - val reposted: Boolean, - ) + val repostUri: String?, + val likedUri: String?, + ) { + val liked by lazy { + likedUri != null + } + + val reposted by lazy { + repostUri != null + } + } override val itemKey: String by lazy { statusKey.toString() + repostBy?.let { "_reblog_${it.userKey}" }.orEmpty() diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Bluesky.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Bluesky.kt index 1c2f7127a..015036159 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Bluesky.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Bluesky.kt @@ -48,8 +48,8 @@ internal fun FeedViewPost.toUi(accountKey: MicroBlogKey): UiStatus.Bluesky { card = findCard(this), reaction = UiStatus.Bluesky.Reaction( - liked = viewer?.like?.atUri != null, - reposted = viewer?.repost?.atUri != null, + repostUri = viewer?.repost?.atUri, + likedUri = viewer?.like?.atUri, ), matrices = UiStatus.Bluesky.Matrices( @@ -57,6 +57,8 @@ internal fun FeedViewPost.toUi(accountKey: MicroBlogKey): UiStatus.Bluesky { likeCount = likeCount ?: 0, repostCount = repostCount ?: 0, ), + cid = cid.cid, + uri = uri.atUri, ) } } @@ -92,8 +94,8 @@ internal fun PostView.toUi(accountKey: MicroBlogKey): UiStatus.Bluesky { card = findCard(this), reaction = UiStatus.Bluesky.Reaction( - liked = viewer?.like?.atUri != null, - reposted = viewer?.repost?.atUri != null, + repostUri = viewer?.repost?.atUri, + likedUri = viewer?.like?.atUri, ), matrices = UiStatus.Bluesky.Matrices( @@ -101,6 +103,8 @@ internal fun PostView.toUi(accountKey: MicroBlogKey): UiStatus.Bluesky { likeCount = likeCount ?: 0, repostCount = repostCount ?: 0, ), + cid = cid.cid, + uri = uri.atUri, ) } @@ -218,10 +222,11 @@ private fun toUi( } }.firstOrNull(), user = record.value.author.toUi(accountKey.host), + // TODO: add reaction reaction = UiStatus.Bluesky.Reaction( - liked = false, - reposted = false, + repostUri = null, + likedUri = null, ), matrices = UiStatus.Bluesky.Matrices( @@ -229,6 +234,8 @@ private fun toUi( likeCount = 0, repostCount = 0, ), + cid = record.value.cid.cid, + uri = record.value.uri.atUri, ) else -> null diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Database.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Database.kt index 53cb2982b..520a6ecb7 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Database.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Database.kt @@ -29,18 +29,20 @@ internal fun DbPagingTimelineWithStatusView.toUi(): UiStatus { accountKey = timeline_account_key, ) is StatusContent.Bluesky -> - status.data.toUi( - accountKey = timeline_account_key, - ) + if (status.reason != null) { + status.reason.toUi( + accountKey = timeline_account_key, + data = status.data, + ) + } else { + status.data.toUi( + accountKey = timeline_account_key, + ) + } is StatusContent.BlueskyNotification -> status.data.toUi( accountKey = timeline_account_key, ) - is StatusContent.BlueskyReason -> - status.reason.toUi( - accountKey = timeline_account_key, - data = status.data, - ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/action/BlueskyReportStatusPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/action/BlueskyReportStatusPresenter.kt new file mode 100644 index 000000000..efa6b978a --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/action/BlueskyReportStatusPresenter.kt @@ -0,0 +1,100 @@ +package dev.dimension.flare.ui.presenter.status.action + +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 app.cash.paging.compose.collectAsLazyPagingItems +import dev.dimension.flare.data.datasource.bluesky.BlueskyDataSource +import dev.dimension.flare.data.repository.activeAccountServicePresenter +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiState +import dev.dimension.flare.ui.model.UiStatus +import dev.dimension.flare.ui.model.flatMap +import dev.dimension.flare.ui.model.map +import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.presenter.PresenterBase +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.koin.compose.rememberKoinInject + +class BlueskyReportStatusPresenter( + private val statusKey: MicroBlogKey, +) : PresenterBase() { + @Composable + override fun body(): BlueskyReportStatusState { + val service = + activeAccountServicePresenter().map { (service, _) -> + service as BlueskyDataSource + } + val status = + activeAccountServicePresenter().map { (service, account) -> + remember(account.accountKey, statusKey) { + service.status(statusKey) + }.collectAsLazyPagingItems() + }.flatMap { + if (it.itemCount == 0) { + UiState.Loading() + } else { + val item = it[0] + if (item == null || item !is UiStatus.Bluesky) { + UiState.Loading() + } else { + UiState.Success(item) + } + } + } + var reason by remember { mutableStateOf(null) } + // using io scope because it's a long-running operation + val scope = rememberKoinInject() + return object : BlueskyReportStatusState { + override val allReasons = BlueskyReportStatusState.ReportReason.entries.toImmutableList() + override val reason: BlueskyReportStatusState.ReportReason? + get() = reason + + override val status: UiState + get() = status + + override fun report( + value: BlueskyReportStatusState.ReportReason, + status: UiStatus.Bluesky, + ) { + service.onSuccess { + scope.launch { + it.report(status, value) + } + } + } + + override fun selectReason(value: BlueskyReportStatusState.ReportReason) { + reason = value + } + } + } +} + +interface BlueskyReportStatusState { + val reason: ReportReason? + val status: UiState + + val allReasons: ImmutableList + + enum class ReportReason { + Spam, + Violation, + Misleading, + Sexual, + Rude, + Other, + } + + fun selectReason(value: ReportReason) + + fun report( + value: ReportReason, + status: UiStatus.Bluesky, + ) +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/action/DeleteStatusPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/action/DeleteStatusPresenter.kt new file mode 100644 index 000000000..03394a3fd --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/action/DeleteStatusPresenter.kt @@ -0,0 +1,40 @@ +package dev.dimension.flare.ui.presenter.status.action + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import dev.dimension.flare.data.repository.activeAccountServicePresenter +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.map +import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.presenter.PresenterBase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.koin.compose.rememberKoinInject + +class DeleteStatusPresenter( + private val statusKey: MicroBlogKey, +) : PresenterBase() { + @Composable + override fun body(): DeleteStatusState { + val service = + activeAccountServicePresenter().map { (service, _) -> + service + } + // using io scope because it's a long-running operation + val scope = rememberKoinInject() + return object : DeleteStatusState { + override fun delete() { + service.onSuccess { + scope.launch { + it.deleteStatus(statusKey) + } + } + } + } + } +} + +interface DeleteStatusState { + fun delete() +} diff --git a/shared/src/commonMain/sqldelight/cache/dev/dimension/flare/data/cache/DbPagingTimeline.sq b/shared/src/commonMain/sqldelight/cache/dev/dimension/flare/data/cache/DbPagingTimeline.sq index 47888c508..d99e84d73 100644 --- a/shared/src/commonMain/sqldelight/cache/dev/dimension/flare/data/cache/DbPagingTimeline.sq +++ b/shared/src/commonMain/sqldelight/cache/dev/dimension/flare/data/cache/DbPagingTimeline.sq @@ -36,6 +36,9 @@ DELETE FROM DbPagingTimeline WHERE account_key = :account_key AND paging_key = : deletePaging: DELETE FROM DbPagingTimeline WHERE account_key = :account_key AND paging_key = :paging_key; +deleteStatus: +DELETE FROM DbPagingTimeline WHERE account_key = :account_key AND status_key = :status_key; + existsPaging: SELECT EXISTS(SELECT 1 FROM DbPagingTimeline WHERE account_key = :account_key AND paging_key = :paging_key); diff --git a/shared/src/commonMain/sqldelight/cache/dev/dimension/flare/data/cache/DbStatus.sq b/shared/src/commonMain/sqldelight/cache/dev/dimension/flare/data/cache/DbStatus.sq index 6ade9abc9..c205fe15e 100644 --- a/shared/src/commonMain/sqldelight/cache/dev/dimension/flare/data/cache/DbStatus.sq +++ b/shared/src/commonMain/sqldelight/cache/dev/dimension/flare/data/cache/DbStatus.sq @@ -20,4 +20,7 @@ get: SELECT * FROM DbStatus WHERE status_key = :status_key AND account_key = :account_key; update: -UPDATE DbStatus SET content = :content WHERE status_key = :status_key AND account_key = :account_key; \ No newline at end of file +UPDATE DbStatus SET content = :content WHERE status_key = :status_key AND account_key = :account_key; + +delete: +DELETE FROM DbStatus WHERE status_key = :status_key AND account_key = :account_key; \ No newline at end of file