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