diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml
index ac16bd8..1e6148f 100644
--- a/.github/workflows/android-ci.yml
+++ b/.github/workflows/android-ci.yml
@@ -24,7 +24,12 @@ jobs:
java-version: '17'
- name: Set up Android SDK
+ # `packages` is pinned because the action's default (`tools platform-tools`)
+ # installs the obsolete `tools` package, which Google has removed from the
+ # SDK repository — sdkmanager now exits 1 with "Failed to find package 'tools'".
uses: android-actions/setup-android@v3
+ with:
+ packages: platform-tools
- name: Install SDK packages
run: sdkmanager "platforms;android-35" "build-tools;35.0.0" "platform-tools"
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 2868454..3f4747f 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -80,6 +80,11 @@ dependencies {
ksp(libs.hilt.compiler)
implementation(libs.androidx.hilt.navigation.compose)
+ // WorkManager + Hilt worker factory (bootstraps the documents delta-sync @HiltWorker)
+ implementation(libs.androidx.work.runtime.ktx)
+ implementation(libs.androidx.hilt.work)
+ ksp(libs.androidx.hilt.compiler)
+
// Test
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.ext.junit)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 759d7c7..b99fdcc 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,8 @@
xmlns:tools="http://schemas.android.com/tools">
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/interlinedlist/android/InterlinedListApplication.kt b/app/src/main/java/com/interlinedlist/android/InterlinedListApplication.kt
index d33d7f7..5c1092d 100644
--- a/app/src/main/java/com/interlinedlist/android/InterlinedListApplication.kt
+++ b/app/src/main/java/com/interlinedlist/android/InterlinedListApplication.kt
@@ -1,8 +1,35 @@
package com.interlinedlist.android
import android.app.Application
+import androidx.hilt.work.HiltWorkerFactory
+import androidx.work.Configuration
+import com.interlinedlist.android.feature.notifications.push.SystemNotificationChannels
import dagger.hilt.android.HiltAndroidApp
+import javax.inject.Inject
-/** Application entry point; bootstraps the Hilt dependency graph. */
+/**
+ * Application entry point; bootstraps the Hilt dependency graph.
+ *
+ * Also supplies the WorkManager [Configuration] on demand (paired with removing the
+ * default `androidx.startup` WorkManager initializer in the manifest) so `@HiltWorker`
+ * instances — such as the documents delta-sync worker and the notifications poll
+ * worker — can be constructed by Hilt.
+ */
@HiltAndroidApp
-class InterlinedListApplication : Application()
+class InterlinedListApplication : Application(), Configuration.Provider {
+
+ @Inject
+ lateinit var workerFactory: HiltWorkerFactory
+
+ override val workManagerConfiguration: Configuration
+ get() = Configuration.Builder()
+ .setWorkerFactory(workerFactory)
+ .build()
+
+ override fun onCreate() {
+ super.onCreate()
+ // Register the system notification channels up front (idempotent, no-op < O)
+ // so the background poll can post into named channels the user can tune.
+ SystemNotificationChannels.ensureRegistered(this)
+ }
+}
diff --git a/app/src/main/java/com/interlinedlist/android/MainActivity.kt b/app/src/main/java/com/interlinedlist/android/MainActivity.kt
index 5a8d172..01ae740 100644
--- a/app/src/main/java/com/interlinedlist/android/MainActivity.kt
+++ b/app/src/main/java/com/interlinedlist/android/MainActivity.kt
@@ -13,6 +13,7 @@ import com.interlinedlist.android.core.datastore.ThemeMode
import com.interlinedlist.android.core.datastore.ThemeSettingsStore
import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme
import com.interlinedlist.android.navigation.InterlinedListNavHost
+import com.interlinedlist.android.navigation.NotificationLaunch
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@@ -31,6 +32,9 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
// Resolve the entry screen before composing so there's no login flash.
val startLoggedIn = sessionStore.isLoggedIn
+ // A tapped system notification launches us with deep-link extras; resolve the
+ // pending in-app route so the signed-in shell can navigate straight to it.
+ val notificationRoute = NotificationLaunch.fromIntent(intent)?.route
enableEdgeToEdge()
setContent {
val themeMode by themeSettingsStore.themeMode.collectAsStateWithLifecycle()
@@ -40,7 +44,10 @@ class MainActivity : ComponentActivity() {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
}
InterlinedListTheme(darkTheme = darkTheme) {
- InterlinedListNavHost(startLoggedIn = startLoggedIn)
+ InterlinedListNavHost(
+ startLoggedIn = startLoggedIn,
+ notificationRoute = notificationRoute,
+ )
}
}
}
diff --git a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt
index b39c2c7..cf0ec9d 100644
--- a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt
+++ b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt
@@ -1,5 +1,10 @@
package com.interlinedlist.android.navigation
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
@@ -13,9 +18,13 @@ import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
@@ -36,6 +45,7 @@ import com.interlinedlist.android.feature.directmessages.navigation.navigateToNe
import com.interlinedlist.android.feature.documents.ui.browser.DocumentsFolderRoute
import com.interlinedlist.android.feature.documents.ui.browser.DocumentsRoute
import com.interlinedlist.android.feature.documents.ui.editor.DocumentEditorRoute
+import com.interlinedlist.android.feature.documents.sync.DocumentsSyncScheduler
import com.interlinedlist.android.feature.documents.ui.share.DocumentShareRoute
import com.interlinedlist.android.feature.documents.ui.share.SharedDocumentRoute
import com.interlinedlist.android.feature.documents.ui.collaborators.DocumentCollaboratorsRoute
@@ -56,6 +66,7 @@ import com.interlinedlist.android.feature.lists.ui.watchers.WatchersRoute
import com.interlinedlist.android.feature.messages.ui.detail.MessageDetailRoute
import com.interlinedlist.android.feature.messages.ui.feed.MessagesRoute
import com.interlinedlist.android.feature.messages.ui.scheduled.ScheduledMessagesRoute
+import com.interlinedlist.android.feature.notifications.push.NotificationsSyncScheduler
import com.interlinedlist.android.feature.notifications.ui.NotificationPreferencesRoute
import com.interlinedlist.android.feature.notifications.ui.NotificationsRoute
import com.interlinedlist.android.feature.organizations.ui.detail.OrganizationDetailRoute
@@ -177,7 +188,10 @@ private enum class HomeTab(val route: String, val label: String, val icon: Image
* back stack; sign-out returns to login.
*/
@Composable
-fun InterlinedListNavHost(startLoggedIn: Boolean) {
+fun InterlinedListNavHost(
+ startLoggedIn: Boolean,
+ notificationRoute: String? = null,
+) {
val navController = rememberNavController()
NavHost(
navController = navController,
@@ -197,8 +211,14 @@ fun InterlinedListNavHost(startLoggedIn: Boolean) {
)
}
composable(Routes.MAIN) {
+ val context = LocalContext.current
MainShell(
+ notificationRoute = notificationRoute,
onLoggedOut = {
+ // Stop background sync/poll for the signed-out session. Cancellation
+ // must never crash the sign-out flow, so any failure is swallowed.
+ runCatching { DocumentsSyncScheduler.cancelAll(context) }
+ runCatching { NotificationsSyncScheduler.cancelAll(context) }
navController.navigate(AuthRoutes.GRAPH) {
popUpTo(Routes.MAIN) { inclusive = true }
}
@@ -214,11 +234,45 @@ fun InterlinedListNavHost(startLoggedIn: Boolean) {
* their own back navigation.
*/
@Composable
-private fun MainShell(onLoggedOut: () -> Unit) {
+private fun MainShell(
+ notificationRoute: String? = null,
+ onLoggedOut: () -> Unit,
+) {
val tabNav = rememberNavController()
val backStackEntry by tabNav.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
val onTabRoot = HomeTab.entries.any { it.route == currentRoute }
+ val context = LocalContext.current
+
+ // Bootstrap the notification poll for the signed-in session: register the periodic
+ // near-real-time poll and kick a one-shot so the last-seen marker seeds immediately.
+ // On Android 13+ request POST_NOTIFICATIONS first (silently ignored below 13, where
+ // the permission does not exist). Scheduling must never crash the shell, so failures
+ // are swallowed. Runs once when the shell enters.
+ val requestNotificationsPermission = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission(),
+ ) { /* result ignored: the poll still runs; posting is a no-op if denied */ }
+ LaunchedEffect(Unit) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ val granted = ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.POST_NOTIFICATIONS,
+ ) == PackageManager.PERMISSION_GRANTED
+ if (!granted) requestNotificationsPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ runCatching {
+ NotificationsSyncScheduler.schedulePeriodic(context)
+ NotificationsSyncScheduler.syncNow(context)
+ }
+ }
+
+ // Route straight to a tapped notification's destination once, when present.
+ val pendingRoute by rememberUpdatedState(notificationRoute)
+ LaunchedEffect(Unit) {
+ pendingRoute?.let { route ->
+ runCatching { tabNav.navigate(route) }
+ }
+ }
Scaffold(
bottomBar = {
@@ -345,6 +399,16 @@ private fun MainShell(onLoggedOut: () -> Unit) {
// ---- Documents ----
composable(Routes.DOCUMENTS) {
+ // Bootstrap the documents delta-sync: register the periodic pull/push and
+ // kick a one-shot sync when the Documents tab is opened. Scheduling must
+ // never crash the UI, so any failure is swallowed defensively.
+ val context = LocalContext.current
+ LaunchedEffect(Unit) {
+ runCatching {
+ DocumentsSyncScheduler.schedulePeriodic(context)
+ DocumentsSyncScheduler.syncNow(context)
+ }
+ }
DocumentsRoute(
onOpenFolder = { id -> tabNav.navigate(Routes.documentFolder(id)) },
onOpenDocument = { id -> tabNav.navigate(Routes.documentEditor(id)) },
diff --git a/app/src/main/java/com/interlinedlist/android/navigation/NotificationLaunch.kt b/app/src/main/java/com/interlinedlist/android/navigation/NotificationLaunch.kt
new file mode 100644
index 0000000..4034325
--- /dev/null
+++ b/app/src/main/java/com/interlinedlist/android/navigation/NotificationLaunch.kt
@@ -0,0 +1,48 @@
+package com.interlinedlist.android.navigation
+
+import android.content.Intent
+import com.interlinedlist.android.feature.notifications.push.NotificationDeepLink
+
+/**
+ * A pending in-app destination derived from a tapped system notification. The
+ * notifications module attaches [NotificationDeepLink] extras to the launch intent;
+ * this parses them into a concrete nav route (falling back to the notifications feed),
+ * so the tap lands the signed-in user on the right screen.
+ */
+data class NotificationLaunch(val route: String) {
+
+ companion object {
+
+ /**
+ * Parses [intent] into a [NotificationLaunch], or null when it did not
+ * originate from a notification tap. Unresolvable targets fall back to the
+ * in-app notifications feed.
+ */
+ fun fromIntent(intent: Intent?): NotificationLaunch? {
+ if (intent?.getBooleanExtra(NotificationDeepLink.EXTRA_FROM_NOTIFICATION, false) != true) {
+ return null
+ }
+ val destination = runCatching {
+ NotificationDeepLink.Destination.valueOf(
+ intent.getStringExtra(NotificationDeepLink.EXTRA_DESTINATION).orEmpty(),
+ )
+ }.getOrDefault(NotificationDeepLink.Destination.NOTIFICATIONS)
+
+ val targetId = intent.getStringExtra(NotificationDeepLink.EXTRA_TARGET_ID)
+ return NotificationLaunch(routeFor(destination, targetId))
+ }
+
+ private fun routeFor(
+ destination: NotificationDeepLink.Destination,
+ targetId: String?,
+ ): String = when (destination) {
+ NotificationDeepLink.Destination.MESSAGE ->
+ targetId?.let { Routes.messageDetail(it) } ?: Routes.NOTIFICATIONS
+ NotificationDeepLink.Destination.USER ->
+ targetId?.let { Routes.userProfile(it) } ?: Routes.NOTIFICATIONS
+ NotificationDeepLink.Destination.LIST ->
+ targetId?.let { Routes.listDetail(it) } ?: Routes.NOTIFICATIONS
+ NotificationDeepLink.Destination.NOTIFICATIONS -> Routes.NOTIFICATIONS
+ }
+ }
+}
diff --git a/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt b/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt
index 7edf96a..f61c7cf 100644
--- a/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt
+++ b/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt
@@ -4,15 +4,19 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme
+import com.interlinedlist.android.feature.messages.domain.LinkedNetwork
import com.interlinedlist.android.feature.messages.domain.Message
+import com.interlinedlist.android.feature.messages.ui.components.EditMessageSheetTags
import com.interlinedlist.android.feature.messages.ui.components.MessageCardTags
import com.interlinedlist.android.feature.messages.ui.components.MessageMediaTags
+import com.interlinedlist.android.feature.messages.ui.components.ModerationDialogTags
import com.interlinedlist.android.feature.messages.ui.components.ReportDialogTags
import org.junit.Rule
import org.junit.Test
@@ -28,11 +32,13 @@ class MessagesFeedScreenTest {
id: String,
body: String,
imageUrls: List = emptyList(),
+ mine: Boolean = false,
+ editedAt: String? = null,
) = Message(
id = id, content = body, authorId = "u1", authorUsername = "adron",
authorDisplayName = "Adron", authorAvatarUrl = null, createdAt = null,
- digCount = 0, replyCount = 0, dugByMe = false, parentId = null, mine = false,
- imageUrls = imageUrls,
+ digCount = 0, replyCount = 0, dugByMe = false, parentId = null, mine = mine,
+ imageUrls = imageUrls, editedAt = editedAt,
)
/** Hosts the stateless feed with a tiny in-memory state holder. */
@@ -40,6 +46,10 @@ class MessagesFeedScreenTest {
initial: MessagesFeedUiState,
onOpenMessage: (String) -> Unit = {},
onReport: (Message) -> Unit = {},
+ onEdit: (Message) -> Unit = {},
+ onBlockUser: (Message) -> Unit = {},
+ onMuteUser: (Message) -> Unit = {},
+ onReportUser: (Message) -> Unit = {},
) {
composeRule.setContent {
var state by mutableStateOf(initial)
@@ -55,7 +65,19 @@ class MessagesFeedScreenTest {
onDismissCompose = { state = state.copy(isComposeOpen = false) },
onComposeTextChange = { state = state.copy(composeText = it) },
onPost = {},
+ onToggleNetwork = { id ->
+ val selected = if (id in state.selectedNetworkIds) {
+ state.selectedNetworkIds - id
+ } else {
+ state.selectedNetworkIds + id
+ }
+ state = state.copy(selectedNetworkIds = selected)
+ },
onReport = onReport,
+ onEdit = onEdit,
+ onBlockUser = onBlockUser,
+ onMuteUser = onMuteUser,
+ onReportUser = onReportUser,
)
}
}
@@ -125,9 +147,105 @@ class MessagesFeedScreenTest {
composeRule.onNodeWithTag(ReportDialogTags.DIALOG).assertIsDisplayed()
}
+ @Test
+ fun overflowMenu_offersEdit_onOwnMessage() {
+ var edited: String? = null
+ setFeed(
+ MessagesFeedUiState(messages = listOf(message("mine1", "my post", mine = true))),
+ onEdit = { edited = it.id },
+ )
+ composeRule.onNodeWithTag(MessageCardTags.MENU).performClick()
+ composeRule.onNodeWithTag(MessageCardTags.EDIT).performClick()
+ assert(edited == "mine1")
+ }
+
+ @Test
+ fun overflowMenu_offersAuthorModeration_onOthersMessage() {
+ var blocked: String? = null
+ var muted: String? = null
+ var reportedUser: String? = null
+ setFeed(
+ MessagesFeedUiState(messages = listOf(message("77", "not mine"))),
+ onBlockUser = { blocked = it.id },
+ onMuteUser = { muted = it.id },
+ onReportUser = { reportedUser = it.id },
+ )
+ composeRule.onNodeWithTag(MessageCardTags.MENU).performClick()
+ composeRule.onNodeWithTag(MessageCardTags.BLOCK_USER).assertIsDisplayed()
+ composeRule.onNodeWithTag(MessageCardTags.MUTE_USER).assertIsDisplayed()
+ composeRule.onNodeWithTag(MessageCardTags.REPORT_USER).performClick()
+ assert(reportedUser == "77")
+ }
+
+ @Test
+ fun editSheet_isShown_whenEditTargetIsSet() {
+ setFeed(
+ MessagesFeedUiState(
+ editTarget = message("mine1", "my post", mine = true),
+ editText = "my post",
+ ),
+ )
+ composeRule.onNodeWithTag(EditMessageSheetTags.INPUT).assertIsDisplayed()
+ }
+
+ @Test
+ fun moderationDialog_isShown_whenModerationTargetIsSet() {
+ setFeed(
+ MessagesFeedUiState(
+ moderationTarget = com.interlinedlist.android.feature.messages.ui.feed.ModerationTarget(
+ message = message("77", "not mine"),
+ action = com.interlinedlist.android.feature.messages.ui.feed.ModerationAction.BLOCK,
+ ),
+ ),
+ )
+ composeRule.onNodeWithTag(ModerationDialogTags.DIALOG).assertIsDisplayed()
+ }
+
+ @Test
+ fun editedMarker_isShown_forEditedMessage() {
+ setFeed(
+ MessagesFeedUiState(
+ messages = listOf(message("1", "edited body", editedAt = "2026-07-31T12:00:00Z")),
+ ),
+ )
+ composeRule.onNodeWithTag(MessageCardTags.EDITED).assertIsDisplayed()
+ }
+
@Test
fun scheduledAction_isPresent_inTheTopBar() {
setFeed(MessagesFeedUiState(messages = listOf(message("1", "hi"))))
composeRule.onNodeWithTag(MessagesFeedTags.SCHEDULED_ACTION).assertIsDisplayed()
}
+
+ @Test
+ fun destinationsRow_rendersInterlinedListAndLinkedNetwork_andTogglesIt() {
+ val linkedIn = LinkedNetwork(id = "l1", provider = "linkedin", providerUsername = "Adron Hall")
+ setFeed(
+ MessagesFeedUiState(
+ isComposeOpen = true,
+ composeText = "cross-post me",
+ linkedNetworks = listOf(linkedIn),
+ ),
+ )
+
+ // InterlinedList is always present; the linked network chip is offered too.
+ composeRule.onNodeWithTag(MessagesFeedTags.DESTINATION_IL).assertIsDisplayed()
+ composeRule.onNodeWithTag(MessagesFeedTags.destinationTag("l1")).assertIsDisplayed()
+
+ // Tapping the LinkedIn chip selects it as a cross-post target.
+ composeRule.onNodeWithTag(MessagesFeedTags.destinationTag("l1")).performClick()
+ composeRule.onNodeWithTag(MessagesFeedTags.destinationTag("l1")).assertIsSelected()
+ }
+
+ @Test
+ fun destinationsHint_isShown_whenNoNetworksAreLinked() {
+ setFeed(
+ MessagesFeedUiState(
+ isComposeOpen = true,
+ composeText = "hi",
+ linkedNetworks = emptyList(),
+ ),
+ )
+ composeRule.onNodeWithTag(MessagesFeedTags.DESTINATIONS_HINT).assertIsDisplayed()
+ }
}
diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt
index 9b8b35d..b1bf7bc 100644
--- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt
+++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt
@@ -8,10 +8,15 @@ import com.interlinedlist.android.feature.messages.data.local.toDomain
import com.interlinedlist.android.feature.messages.data.local.toEntity
import com.interlinedlist.android.feature.messages.data.remote.MessagesApi
import com.interlinedlist.android.feature.messages.data.remote.dto.CreateMessageRequest
+import com.interlinedlist.android.feature.messages.data.remote.dto.EditMessageRequest
import com.interlinedlist.android.feature.messages.data.remote.dto.PaginationDto
import com.interlinedlist.android.feature.messages.data.remote.dto.ReportRequest
+import com.interlinedlist.android.feature.messages.data.remote.dto.UserReportRequest
import com.interlinedlist.android.feature.messages.data.remote.dto.toDomain
import com.interlinedlist.android.core.network.error.safeApiCall
+import com.interlinedlist.android.feature.messages.domain.CreatedMessage
+import com.interlinedlist.android.feature.messages.domain.CrossPostSelection
+import com.interlinedlist.android.feature.messages.domain.LinkedNetwork
import com.interlinedlist.android.feature.messages.domain.Message
import com.interlinedlist.android.feature.messages.domain.ReportReason
import kotlinx.coroutines.flow.Flow
@@ -81,12 +86,19 @@ class DefaultMessagesRepository @Inject constructor(
imageUrls: List,
videoUrls: List,
scheduledAt: String?,
- ): ApiResult = withContext(dispatchers.io) {
+ crossPost: CrossPostSelection,
+ ): ApiResult = withContext(dispatchers.io) {
val request = CreateMessageRequest(
content = content,
imageUrls = imageUrls.ifEmpty { null },
videoUrls = videoUrls.ifEmpty { null },
scheduledAt = scheduledAt,
+ // Encode cross-post targets per the create schema. explicitNulls=false
+ // drops these when empty/false, so a plain post keeps its original body.
+ mastodonProviderIds = crossPost.mastodonProviderIds.ifEmpty { null },
+ crossPostToBluesky = crossPost.bluesky.takeIf { it },
+ crossPostToLinkedIn = crossPost.linkedIn.takeIf { it },
+ crossPostToTwitter = crossPost.twitter.takeIf { it },
)
when (val result = safeCall { api.createMessage(request) }) {
is ApiResult.Success -> {
@@ -99,12 +111,20 @@ class DefaultMessagesRepository @Inject constructor(
val topOrder = (messageDao.maxFeedOrder() ?: 0L)
messageDao.upsert(message.toEntity(feedOrder = topOrder - 1L))
}
- ApiResult.Success(message)
+ val crossPosts = result.data.crossPosts.mapNotNull { it.toDomainOrNull() }
+ ApiResult.Success(CreatedMessage(message = message, crossPosts = crossPosts))
}
is ApiResult.Failure -> result
}
}
+ override suspend fun getLinkedNetworks(): ApiResult> = withContext(dispatchers.io) {
+ when (val result = safeCall { api.getIdentities() }) {
+ is ApiResult.Success -> ApiResult.Success(result.data.identities.map { it.toDomain() })
+ is ApiResult.Failure -> result
+ }
+ }
+
override suspend fun uploadImage(
bytes: ByteArray,
fileName: String,
@@ -195,6 +215,33 @@ class DefaultMessagesRepository @Inject constructor(
}
}
+ override suspend fun editMessage(messageId: String, content: String): ApiResult =
+ withContext(dispatchers.io) {
+ // Optimistically apply the new content + an "edited" marker so the feed
+ // and detail react immediately; roll back the whole row on failure.
+ val previous = currentEntity(messageId)
+ val editedAt = nowIso()
+ if (previous != null) {
+ messageDao.upsert(previous.copy(content = content, editedAt = editedAt))
+ }
+ when (val result = safeCall { api.editMessage(messageId, EditMessageRequest(content = content)) }) {
+ is ApiResult.Success -> {
+ val updated = (previous?.copy(content = content, editedAt = editedAt))?.toDomain()
+ ?: Message(
+ id = messageId, content = content, authorId = "", authorUsername = "",
+ authorDisplayName = null, authorAvatarUrl = null, createdAt = null,
+ digCount = 0, replyCount = 0, dugByMe = false, parentId = null,
+ mine = true, editedAt = editedAt,
+ )
+ ApiResult.Success(updated)
+ }
+ is ApiResult.Failure -> {
+ if (previous != null) messageDao.upsert(previous)
+ result
+ }
+ }
+ }
+
override suspend fun refreshScheduled(): ApiResult = withContext(dispatchers.io) {
when (val result = safeCall { api.getScheduled() }) {
is ApiResult.Success -> {
@@ -232,6 +279,44 @@ class DefaultMessagesRepository @Inject constructor(
}
}
+ override suspend fun blockUser(username: String): ApiResult = withContext(dispatchers.io) {
+ when (val result = safeCall { api.blockUser(username) }) {
+ is ApiResult.Success -> {
+ // Hide the blocked author's messages from the local cache.
+ messageDao.deleteByAuthorUsername(username)
+ ApiResult.Success(Unit)
+ }
+ is ApiResult.Failure -> result
+ }
+ }
+
+ override suspend fun muteUser(username: String): ApiResult = withContext(dispatchers.io) {
+ when (val result = safeCall { api.muteUser(username) }) {
+ is ApiResult.Success -> {
+ // Hide the muted author's messages from the local cache.
+ messageDao.deleteByAuthorUsername(username)
+ ApiResult.Success(Unit)
+ }
+ is ApiResult.Failure -> result
+ }
+ }
+
+ override suspend fun reportUser(
+ username: String,
+ reason: ReportReason,
+ detail: String?,
+ ): ApiResult = withContext(dispatchers.io) {
+ safeCall {
+ api.reportUser(
+ username = username,
+ body = UserReportRequest(
+ reason = reason.wireValue,
+ detail = detail?.takeIf { it.isNotBlank() },
+ ),
+ )
+ }
+ }
+
override suspend fun fetchMetadata(messageId: String): ApiResult = withContext(dispatchers.io) {
when (val result = safeCall { api.fetchMetadata(messageId) }) {
is ApiResult.Success -> {
@@ -284,6 +369,9 @@ class DefaultMessagesRepository @Inject constructor(
private fun currentUserId(): String? = sessionStore.userId
+ /** Current instant as an ISO-8601 string, for the optimistic "edited" marker. */
+ private fun nowIso(): String = java.time.Instant.now().toString()
+
/** Shared multipart upload path; extracts the hosted URL from the response. */
private suspend fun upload(
bytes: ByteArray,
diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt
index c5986f0..5d0cf76 100644
--- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt
+++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt
@@ -1,6 +1,9 @@
package com.interlinedlist.android.feature.messages.data
import com.interlinedlist.android.core.common.result.ApiResult
+import com.interlinedlist.android.feature.messages.domain.CreatedMessage
+import com.interlinedlist.android.feature.messages.domain.CrossPostSelection
+import com.interlinedlist.android.feature.messages.domain.LinkedNetwork
import com.interlinedlist.android.feature.messages.domain.Message
import com.interlinedlist.android.feature.messages.domain.ReportReason
import kotlinx.coroutines.flow.Flow
@@ -38,15 +41,26 @@ interface MessagesRepository {
/**
* Creates a new top-level message and caches it. Optionally attaches already
- * uploaded [imageUrls] / [videoUrls] and defers publishing to [scheduledAt]
- * (ISO-8601). A scheduled message does not enter the feed cache.
+ * uploaded [imageUrls] / [videoUrls], defers publishing to [scheduledAt]
+ * (ISO-8601), and cross-posts to the already-linked networks named by
+ * [crossPost] (InterlinedList-only when [CrossPostSelection.NONE]). A scheduled
+ * message does not enter the feed cache. Returns the created message plus any
+ * per-network cross-post delivery statuses the endpoint reported.
*/
suspend fun createMessage(
content: String,
imageUrls: List = emptyList(),
videoUrls: List = emptyList(),
scheduledAt: String? = null,
- ): ApiResult
+ crossPost: CrossPostSelection = CrossPostSelection.NONE,
+ ): ApiResult
+
+ /**
+ * The caller's already-linked social networks, offered as cross-post
+ * destinations in the composer. Read directly from the API (not cached); an
+ * empty list means nothing is linked yet.
+ */
+ suspend fun getLinkedNetworks(): ApiResult>
/** Uploads image [bytes] and returns the hosted URL to attach on compose. */
suspend fun uploadImage(bytes: ByteArray, fileName: String, mimeType: String): ApiResult
@@ -69,6 +83,13 @@ interface MessagesRepository {
/** Deletes one of the caller's own messages, removing it from the cache. */
suspend fun deleteMessage(messageId: String): ApiResult
+ /**
+ * Edits the [content] of one of the caller's own messages. Optimistically
+ * updates the cached message (content + an "edited" marker) and rolls the
+ * change back on failure. Returns the updated [Message].
+ */
+ suspend fun editMessage(messageId: String, content: String): ApiResult
+
/** Refreshes the caller's scheduled messages from the API into the cache. */
suspend fun refreshScheduled(): ApiResult
@@ -78,6 +99,21 @@ interface MessagesRepository {
/** Reports a message with a [reason] and optional free-text [detail]. */
suspend fun report(messageId: String, reason: ReportReason, detail: String? = null): ApiResult
+ /**
+ * Blocks the user [username]. On success, removes that author's messages from
+ * the local feed/reply cache so the caller stops seeing them immediately.
+ */
+ suspend fun blockUser(username: String): ApiResult
+
+ /**
+ * Mutes the user [username]. On success, removes that author's messages from
+ * the local feed/reply cache so the caller stops seeing them immediately.
+ */
+ suspend fun muteUser(username: String): ApiResult
+
+ /** Reports the user [username] with a [reason] and optional free-text [detail]. */
+ suspend fun reportUser(username: String, reason: ReportReason, detail: String? = null): ApiResult
+
/**
* Fetches link-preview metadata for [messageId]'s links and updates the cached
* message so the feed/detail can render a preview card.
diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt
index 943547f..a19a63a 100644
--- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt
+++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt
@@ -39,6 +39,13 @@ interface MessageDao {
@Query("DELETE FROM message WHERE id = :id")
suspend fun deleteById(id: String)
+ /**
+ * Removes every cached message authored by [username] (used to hide a blocked
+ * or muted author's messages from the local feed/replies immediately).
+ */
+ @Query("DELETE FROM message WHERE authorUsername = :username")
+ suspend fun deleteByAuthorUsername(username: String)
+
/** Clears the top-level feed (used before writing a fresh refresh page). */
@Query("DELETE FROM message WHERE parentId IS NULL AND scheduledAt IS NULL")
suspend fun clearFeed()
diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt
index 3d81d1c..21a9d04 100644
--- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt
+++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt
@@ -34,6 +34,8 @@ data class MessageEntity(
val linkPreview: LinkPreview? = null,
/** Future send time for a scheduled message; null for a normal message. */
val scheduledAt: String? = null,
+ /** Last-edited instant; null when the message has not been edited. */
+ val editedAt: String? = null,
)
fun MessageEntity.toDomain(): Message = Message(
@@ -53,6 +55,7 @@ fun MessageEntity.toDomain(): Message = Message(
videoUrls = videoUrls,
linkPreview = linkPreview,
scheduledAt = scheduledAt,
+ editedAt = editedAt,
)
fun Message.toEntity(feedOrder: Long): MessageEntity = MessageEntity(
@@ -73,4 +76,5 @@ fun Message.toEntity(feedOrder: Long): MessageEntity = MessageEntity(
videoUrls = videoUrls,
linkPreview = linkPreview,
scheduledAt = scheduledAt,
+ editedAt = editedAt,
)
diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt
index 5415994..fe84d22 100644
--- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt
+++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt
@@ -2,17 +2,21 @@ package com.interlinedlist.android.feature.messages.data.remote
import com.interlinedlist.android.feature.messages.data.remote.dto.CreateMessageRequest
import com.interlinedlist.android.feature.messages.data.remote.dto.CreateMessageResponse
+import com.interlinedlist.android.feature.messages.data.remote.dto.EditMessageRequest
+import com.interlinedlist.android.feature.messages.data.remote.dto.IdentitiesResponse
import com.interlinedlist.android.feature.messages.data.remote.dto.MediaUploadResponse
import com.interlinedlist.android.feature.messages.data.remote.dto.MessageResponse
import com.interlinedlist.android.feature.messages.data.remote.dto.MessagesResponse
import com.interlinedlist.android.feature.messages.data.remote.dto.MetadataResponse
import com.interlinedlist.android.feature.messages.data.remote.dto.ReportRequest
import com.interlinedlist.android.feature.messages.data.remote.dto.ScheduledMessagesResponse
+import com.interlinedlist.android.feature.messages.data.remote.dto.UserReportRequest
import okhttp3.MultipartBody
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Multipart
+import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
@@ -57,6 +61,14 @@ interface MessagesApi {
@DELETE("api/messages/{id}")
suspend fun deleteMessage(@Path("id") id: String)
+ /**
+ * Edits the content of one of the caller's own messages. The endpoint's
+ * response body is not modelled in the OpenAPI spec; the repository updates
+ * the cache optimistically and treats a 2xx as success, so this returns Unit.
+ */
+ @PATCH("api/messages/{id}")
+ suspend fun editMessage(@Path("id") id: String, @Body body: EditMessageRequest)
+
/** Full-text search over top-level messages. */
@GET("api/messages/search")
suspend fun search(
@@ -79,6 +91,14 @@ interface MessagesApi {
@GET("api/messages/scheduled")
suspend fun getScheduled(): ScheduledMessagesResponse
+ /**
+ * The caller's already-linked social identities (Mastodon/LinkedIn/X/Bluesky),
+ * used to offer cross-post destinations in the composer. Linking new accounts
+ * is a web-only OAuth flow and is not exposed here.
+ */
+ @GET("api/user/identities")
+ suspend fun getIdentities(): IdentitiesResponse
+
/** Reports a message with a reason (and optional free-text detail). */
@POST("api/messages/{id}/report")
suspend fun report(@Path("id") id: String, @Body body: ReportRequest)
@@ -86,4 +106,18 @@ interface MessagesApi {
/** Fetches and attaches link-preview metadata for a message's links. */
@POST("api/messages/{id}/metadata")
suspend fun fetchMetadata(@Path("id") id: String): MetadataResponse
+
+ // --- author moderation (on messages by other users) --------------------
+
+ /** Blocks a user by username. */
+ @POST("api/users/{username}/block")
+ suspend fun blockUser(@Path("username") username: String)
+
+ /** Mutes a user by username. */
+ @POST("api/users/{username}/mute")
+ suspend fun muteUser(@Path("username") username: String)
+
+ /** Reports a user with a reason (and optional free-text detail). */
+ @POST("api/users/{username}/report")
+ suspend fun reportUser(@Path("username") username: String, @Body body: UserReportRequest)
}
diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/IdentityDto.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/IdentityDto.kt
new file mode 100644
index 0000000..3a7900d
--- /dev/null
+++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/IdentityDto.kt
@@ -0,0 +1,39 @@
+package com.interlinedlist.android.feature.messages.data.remote.dto
+
+import com.interlinedlist.android.feature.messages.domain.LinkedNetwork
+import kotlinx.serialization.Serializable
+
+/**
+ * Response from `GET /api/user/identities`:
+ * `{ "identities": [ { id, provider, providerUsername, profileUrl, avatarUrl,
+ * connectedAt, lastVerifiedAt } ] }`.
+ *
+ * The endpoint isn't schema-modelled in the OpenAPI spec, so this mirrors the
+ * verified live shape; the shared Json `ignoreUnknownKeys`, so extra fields (e.g.
+ * `lastVerifiedAt`) are tolerated.
+ */
+@Serializable
+data class IdentitiesResponse(
+ val identities: List = emptyList(),
+)
+
+/** A single linked social identity. */
+@Serializable
+data class IdentityDto(
+ val id: String,
+ val provider: String,
+ val providerUsername: String = "",
+ val profileUrl: String? = null,
+ val avatarUrl: String? = null,
+ val connectedAt: String? = null,
+)
+
+/** Maps a wire identity into the domain [LinkedNetwork]. */
+fun IdentityDto.toDomain(): LinkedNetwork = LinkedNetwork(
+ id = id,
+ provider = provider,
+ providerUsername = providerUsername,
+ profileUrl = profileUrl,
+ avatarUrl = avatarUrl,
+ connectedAt = connectedAt,
+)
diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDto.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDto.kt
index 15079ab..bbaae53 100644
--- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDto.kt
+++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDto.kt
@@ -21,6 +21,8 @@ data class MessageDto(
* `author`; [toDomain] falls back to whichever the payload used. */
val user: MessageAuthorDto? = null,
val createdAt: String? = null,
+ /** Last-modified instant; when it differs from [createdAt] the message was edited. */
+ val updatedAt: String? = null,
val digCount: Int = 0,
val replyCount: Int = 0,
val dugByCurrentUser: Boolean = false,
@@ -74,6 +76,8 @@ fun MessageDto.toDomain(currentUserId: String?): Message {
authorDisplayName = person?.displayName,
authorAvatarUrl = person?.avatar,
createdAt = createdAt,
+ // Treat the message as edited only when it was modified after creation.
+ editedAt = updatedAt?.takeIf { createdAt == null || it != createdAt },
digCount = digCount,
replyCount = replyCount,
dugByMe = dugByCurrentUser,
diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt
index c62f929..ab22db9 100644
--- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt
+++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt
@@ -1,5 +1,6 @@
package com.interlinedlist.android.feature.messages.data.remote.dto
+import com.interlinedlist.android.feature.messages.domain.CrossPostStatus
import kotlinx.serialization.Serializable
/**
@@ -36,19 +37,55 @@ data class MessageResponse(
* here `message` is a human-readable status string ("Message created
* successfully") and the created message is under [data]:
* `{ message: "…", data: { …message… }, crossPosts: [...] }`.
+ *
+ * [crossPosts] carries per-network delivery status when the post targeted linked
+ * networks. Its shape is not modelled in the OpenAPI spec, so it is best-effort
+ * and defaults to empty (the shared Json `coerceInputValues`, so an explicit
+ * `null` also becomes the empty default).
*/
@Serializable
data class CreateMessageResponse(
val data: MessageDto,
+ val crossPosts: List = emptyList(),
)
+/**
+ * Per-network delivery status entry in a create response's `crossPosts` array.
+ * Not schema-modelled, so every field is optional; the repository maps only
+ * entries that name a [provider].
+ */
+@Serializable
+data class CrossPostStatusDto(
+ val provider: String? = null,
+ val status: String? = null,
+ val url: String? = null,
+ val error: String? = null,
+) {
+ /** Maps into the domain, or null when the entry names no provider. */
+ fun toDomainOrNull(): CrossPostStatus? {
+ val networkProvider = provider?.takeIf { it.isNotBlank() } ?: return null
+ return CrossPostStatus(
+ provider = networkProvider,
+ status = status?.takeIf { it.isNotBlank() } ?: if (error != null) "failed" else "pending",
+ url = url,
+ error = error,
+ )
+ }
+}
+
/**
* Request body for creating a message or posting a reply.
*
* [imageUrls] / [videoUrls] carry media previously uploaded via the upload
* endpoints, and [scheduledAt] (ISO-8601) defers publishing to a future time.
+ *
+ * Cross-posting targets are encoded per the create schema: [mastodonProviderIds]
+ * lists the selected mastodon identity ids (a user may link several instances),
+ * while [crossPostToBluesky] / [crossPostToLinkedIn] / [crossPostToTwitter] are
+ * single boolean flags for the one-account networks.
+ *
* Only non-null fields are serialised (the shared Json uses `explicitNulls =
- * false`), so a plain post still sends just `{ content }`.
+ * false`), so a plain InterlinedList-only post still sends just `{ content }`.
*/
@Serializable
data class CreateMessageRequest(
@@ -57,6 +94,10 @@ data class CreateMessageRequest(
val imageUrls: List? = null,
val videoUrls: List? = null,
val scheduledAt: String? = null,
+ val mastodonProviderIds: List? = null,
+ val crossPostToBluesky: Boolean? = null,
+ val crossPostToLinkedIn: Boolean? = null,
+ val crossPostToTwitter: Boolean? = null,
)
/**
@@ -90,6 +131,26 @@ data class ReportRequest(
val detail: String? = null,
)
+/**
+ * Request body for editing one of the caller's own messages via
+ * `PATCH /api/messages/{id}`. Only `content` is sent; the shared Json uses
+ * `explicitNulls = false`, so the body is a plain `{ "content": "…" }`.
+ */
+@Serializable
+data class EditMessageRequest(
+ val content: String,
+)
+
+/**
+ * Request body for reporting a *user* via `POST /api/users/{username}/report`.
+ * Mirrors the message [ReportRequest] shape: `{ reason, detail? }`.
+ */
+@Serializable
+data class UserReportRequest(
+ val reason: String,
+ val detail: String? = null,
+)
+
/**
* Response from the metadata endpoint. The updated message (with its populated
* `linkMetadata`) is returned either at the top level or under `message`.
diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/CrossPost.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/CrossPost.kt
new file mode 100644
index 0000000..e74f43e
--- /dev/null
+++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/CrossPost.kt
@@ -0,0 +1,133 @@
+package com.interlinedlist.android.feature.messages.domain
+
+/**
+ * A social network the current user has already linked on the web, returned by
+ * `GET /api/user/identities`. These are the candidate cross-post destinations the
+ * composer offers; linking new accounts (OAuth) happens on the web and is out of
+ * scope for the app.
+ *
+ * [provider] is the raw wire value (e.g. `mastodon:techhub.social`, `linkedin`);
+ * [networkProvider] normalises it to a known [NetworkProvider] for icon/label and,
+ * critically, to decide how the selection is encoded on the create request (a
+ * per-instance mastodon id vs. a single boolean flag for the others).
+ */
+data class LinkedNetwork(
+ /** The identity's stable id (used as a `mastodonProviderIds` entry). */
+ val id: String,
+ /** Raw provider string from the API, e.g. `mastodon:techhub.social`. */
+ val provider: String,
+ /** The handle/display name on that network, for the chip label. */
+ val providerUsername: String,
+ val profileUrl: String? = null,
+ val avatarUrl: String? = null,
+ val connectedAt: String? = null,
+) {
+ /** The normalised provider family, derived from [provider]'s prefix. */
+ val networkProvider: NetworkProvider get() = NetworkProvider.fromWire(provider)
+
+ /**
+ * A short label for the destination chip. Mastodon shows its instance host
+ * (e.g. `techhub.social`) since a user may link more than one; the others
+ * show the single network's display name.
+ */
+ val chipLabel: String
+ get() = when (networkProvider) {
+ NetworkProvider.MASTODON -> provider.substringAfter(':', missingDelimiterValue = "Mastodon")
+ else -> networkProvider.displayName
+ }
+}
+
+/** The cross-post networks the create endpoint understands. */
+enum class NetworkProvider(val displayName: String) {
+ MASTODON("Mastodon"),
+ LINKEDIN("LinkedIn"),
+ TWITTER("X"),
+ BLUESKY("Bluesky"),
+ /** Any linked provider the app doesn't yet know how to cross-post to. */
+ OTHER("Other");
+
+ companion object {
+ /**
+ * Maps a raw `provider` value to a [NetworkProvider]. Mastodon arrives as
+ * `mastodon:` (per-instance), so match on the prefix; the rest are
+ * plain tokens.
+ */
+ fun fromWire(provider: String): NetworkProvider = when {
+ provider.startsWith("mastodon", ignoreCase = true) -> MASTODON
+ provider.equals("linkedin", ignoreCase = true) -> LINKEDIN
+ provider.equals("twitter", ignoreCase = true) ||
+ provider.equals("x", ignoreCase = true) -> TWITTER
+ provider.equals("bluesky", ignoreCase = true) -> BLUESKY
+ else -> OTHER
+ }
+ }
+}
+
+/**
+ * The set of already-linked networks the composer has selected as cross-post
+ * targets for a post. Built from the toggled [LinkedNetwork]s; the repository
+ * translates it into the discrete create-request fields (a mastodon id list plus
+ * the per-network boolean flags). An empty selection means InterlinedList-only.
+ */
+data class CrossPostSelection(
+ /** Selected mastodon identity ids (a user may link several instances). */
+ val mastodonProviderIds: List = emptyList(),
+ val bluesky: Boolean = false,
+ val linkedIn: Boolean = false,
+ val twitter: Boolean = false,
+) {
+ /** True when at least one external network is targeted. */
+ val hasTargets: Boolean
+ get() = mastodonProviderIds.isNotEmpty() || bluesky || linkedIn || twitter
+
+ companion object {
+ val NONE = CrossPostSelection()
+
+ /** Folds a set of selected [networks] into a [CrossPostSelection]. */
+ fun from(networks: Collection): CrossPostSelection = CrossPostSelection(
+ mastodonProviderIds = networks
+ .filter { it.networkProvider == NetworkProvider.MASTODON }
+ .map { it.id },
+ bluesky = networks.any { it.networkProvider == NetworkProvider.BLUESKY },
+ linkedIn = networks.any { it.networkProvider == NetworkProvider.LINKEDIN },
+ twitter = networks.any { it.networkProvider == NetworkProvider.TWITTER },
+ )
+ }
+}
+
+/**
+ * Per-network delivery status for a cross-posted message, parsed from the create
+ * response's `crossPosts` array. The response shape is not modelled in the OpenAPI
+ * spec, so all fields are best-effort: [provider] identifies the target, [status]
+ * is a free-text state (e.g. `success`, `pending`, `failed`), and [url]/[error]
+ * are surfaced when present.
+ */
+data class CrossPostStatus(
+ val provider: String,
+ val status: String,
+ val url: String? = null,
+ val error: String? = null,
+) {
+ val networkProvider: NetworkProvider get() = NetworkProvider.fromWire(provider)
+
+ /** A human label for the network the post was sent to. */
+ val label: String
+ get() = when (networkProvider) {
+ NetworkProvider.MASTODON -> provider.substringAfter(':', missingDelimiterValue = "Mastodon")
+ NetworkProvider.OTHER -> provider
+ else -> networkProvider.displayName
+ }
+
+ val isSuccess: Boolean get() = status.equals("success", ignoreCase = true) || url != null && error == null
+ val isFailed: Boolean get() = status.equals("failed", ignoreCase = true) || error != null
+}
+
+/**
+ * The outcome of creating a message: the created [message] plus any per-network
+ * [crossPosts] delivery statuses the create endpoint reported. [crossPosts] is
+ * empty for a plain InterlinedList-only post.
+ */
+data class CreatedMessage(
+ val message: Message,
+ val crossPosts: List = emptyList(),
+)
diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/Message.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/Message.kt
index a7348f1..dbfcc1f 100644
--- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/Message.kt
+++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/Message.kt
@@ -34,12 +34,20 @@ data class Message(
* normal message. Present on rows returned by the scheduled endpoint.
*/
val scheduledAt: String? = null,
+ /**
+ * ISO-8601 last-edited instant for a message whose content was changed after
+ * it was posted; null for an un-edited message. Drives the "edited" indicator.
+ */
+ val editedAt: String? = null,
) {
/** Best available display label for the author. */
val authorLabel: String get() = authorDisplayName?.takeIf { it.isNotBlank() } ?: authorUsername
/** True when any image or video media is attached. */
val hasMedia: Boolean get() = imageUrls.isNotEmpty() || videoUrls.isNotEmpty()
+
+ /** True when the message has been edited since it was posted. */
+ val isEdited: Boolean get() = editedAt != null
}
/**
diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/EditMessageSheet.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/EditMessageSheet.kt
new file mode 100644
index 0000000..3a0cfe1
--- /dev/null
+++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/EditMessageSheet.kt
@@ -0,0 +1,96 @@
+package com.interlinedlist.android.feature.messages.ui.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.unit.dp
+
+/** Stable test tags for the in-place edit sheet. */
+object EditMessageSheetTags {
+ const val SHEET = "editMessageSheet"
+ const val INPUT = "editMessageInput"
+ const val SAVE = "editMessageSave"
+}
+
+/**
+ * In-place editor for one of the caller's own messages: a bottom sheet seeded with
+ * the current content. Saving PATCHes the new text; the feed/detail update
+ * optimistically and gain an "edited" marker.
+ *
+ * @param onSave invoked when the user confirms the edit.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun EditMessageSheet(
+ text: String,
+ canSave: Boolean,
+ isSaving: Boolean,
+ onTextChange: (String) -> Unit,
+ onDismiss: () -> Unit,
+ onSave: () -> Unit,
+) {
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ modifier = Modifier.testTag(EditMessageSheetTags.SHEET),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .imePadding()
+ .padding(horizontal = 20.dp, vertical = 12.dp),
+ ) {
+ Text(
+ text = "Edit message",
+ style = MaterialTheme.typography.titleMedium,
+ )
+ Spacer(Modifier.height(12.dp))
+ OutlinedTextField(
+ value = text,
+ onValueChange = onTextChange,
+ placeholder = { Text("Edit your message…") },
+ enabled = !isSaving,
+ minLines = 3,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(EditMessageSheetTags.INPUT),
+ )
+ Spacer(Modifier.height(12.dp))
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+ TextButton(onClick = onDismiss, enabled = !isSaving) { Text("Cancel") }
+ Spacer(Modifier.height(8.dp))
+ Button(
+ onClick = onSave,
+ enabled = canSave,
+ modifier = Modifier.testTag(EditMessageSheetTags.SAVE),
+ ) {
+ if (isSaving) {
+ CircularProgressIndicator(
+ modifier = Modifier.height(20.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary,
+ )
+ } else {
+ Text("Save")
+ }
+ }
+ }
+ Spacer(Modifier.height(12.dp))
+ }
+ }
+}
diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt
index 26ba171..246b6e7 100644
--- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt
+++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt
@@ -45,8 +45,13 @@ object MessageCardTags {
const val REPLY = "messageReply"
const val MENU = "messageMenu"
const val DELETE = "messageDelete"
+ const val EDIT = "messageEdit"
const val REPORT = "messageReport"
+ const val BLOCK_USER = "messageBlockUser"
+ const val MUTE_USER = "messageMuteUser"
+ const val REPORT_USER = "messageReportUser"
const val BODY = "messageBody"
+ const val EDITED = "messageEdited"
}
/**
@@ -62,6 +67,10 @@ fun MessageCard(
onDelete: () -> Unit,
modifier: Modifier = Modifier,
onReport: () -> Unit = {},
+ onEdit: () -> Unit = {},
+ onBlockUser: () -> Unit = {},
+ onMuteUser: () -> Unit = {},
+ onReportUser: () -> Unit = {},
onOpenLink: (String) -> Unit = {},
) {
Row(
@@ -87,8 +96,24 @@ fun MessageCard(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
+ if (message.isEdited) {
+ Text(
+ text = " · edited",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.testTag(MessageCardTags.EDITED),
+ )
+ }
Spacer(Modifier.weight(1f))
- MessageMenu(isMine = message.mine, onDelete = onDelete, onReport = onReport)
+ MessageMenu(
+ isMine = message.mine,
+ onEdit = onEdit,
+ onDelete = onDelete,
+ onReport = onReport,
+ onBlockUser = onBlockUser,
+ onMuteUser = onMuteUser,
+ onReportUser = onReportUser,
+ )
}
Spacer(Modifier.size(4.dp))
Text(
@@ -125,11 +150,20 @@ fun MessageCard(
}
/**
- * Overflow menu: own messages offer Delete; everyone else's offer Report. Renders
- * nothing when there is no applicable action (defensive; both branches are covered).
+ * Overflow menu. Own messages offer Edit + Delete. Everyone else's offer author
+ * moderation — Block user, Mute user, Report user — plus the existing Report
+ * (message) action.
*/
@Composable
-private fun MessageMenu(isMine: Boolean, onDelete: () -> Unit, onReport: () -> Unit) {
+private fun MessageMenu(
+ isMine: Boolean,
+ onEdit: () -> Unit,
+ onDelete: () -> Unit,
+ onReport: () -> Unit,
+ onBlockUser: () -> Unit,
+ onMuteUser: () -> Unit,
+ onReportUser: () -> Unit,
+) {
var expanded by remember { mutableStateOf(false) }
Box {
IconButton(
@@ -140,6 +174,14 @@ private fun MessageMenu(isMine: Boolean, onDelete: () -> Unit, onReport: () -> U
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
if (isMine) {
+ DropdownMenuItem(
+ text = { Text("Edit") },
+ onClick = {
+ expanded = false
+ onEdit()
+ },
+ modifier = Modifier.testTag(MessageCardTags.EDIT),
+ )
DropdownMenuItem(
text = { Text("Delete") },
onClick = {
@@ -150,13 +192,37 @@ private fun MessageMenu(isMine: Boolean, onDelete: () -> Unit, onReport: () -> U
)
} else {
DropdownMenuItem(
- text = { Text("Report") },
+ text = { Text("Report message") },
onClick = {
expanded = false
onReport()
},
modifier = Modifier.testTag(MessageCardTags.REPORT),
)
+ DropdownMenuItem(
+ text = { Text("Block user") },
+ onClick = {
+ expanded = false
+ onBlockUser()
+ },
+ modifier = Modifier.testTag(MessageCardTags.BLOCK_USER),
+ )
+ DropdownMenuItem(
+ text = { Text("Mute user") },
+ onClick = {
+ expanded = false
+ onMuteUser()
+ },
+ modifier = Modifier.testTag(MessageCardTags.MUTE_USER),
+ )
+ DropdownMenuItem(
+ text = { Text("Report user") },
+ onClick = {
+ expanded = false
+ onReportUser()
+ },
+ modifier = Modifier.testTag(MessageCardTags.REPORT_USER),
+ )
}
}
}
diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/ModerationDialog.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/ModerationDialog.kt
new file mode 100644
index 0000000..4e720bd
--- /dev/null
+++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/ModerationDialog.kt
@@ -0,0 +1,118 @@
+package com.interlinedlist.android.feature.messages.ui.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+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.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.unit.dp
+import com.interlinedlist.android.feature.messages.domain.ReportReason
+import com.interlinedlist.android.feature.messages.ui.feed.ModerationAction
+import com.interlinedlist.android.feature.messages.ui.feed.ModerationTarget
+
+/** Stable test tags for the author-moderation confirm dialog. */
+object ModerationDialogTags {
+ const val DIALOG = "moderationDialog"
+ const val DETAIL = "moderationDetail"
+ const val CONFIRM = "moderationConfirm"
+}
+
+/**
+ * Confirmation dialog for an author-moderation action. Block and Mute are simple
+ * confirms; Report additionally collects a [ReportReason] (required) and optional
+ * free-text detail before it can be submitted.
+ *
+ * @param onConfirm invoked with the chosen reason (Report only) and detail.
+ */
+@Composable
+fun ModerationDialog(
+ target: ModerationTarget,
+ isSubmitting: Boolean,
+ onDismiss: () -> Unit,
+ onConfirm: (ReportReason?, String) -> Unit,
+) {
+ val label = target.authorLabel
+ val isReport = target.action == ModerationAction.REPORT
+ var reason by remember { mutableStateOf(null) }
+ var detail by remember { mutableStateOf("") }
+
+ val (title, body, confirmLabel) = when (target.action) {
+ ModerationAction.BLOCK ->
+ Triple("Block $label?", "You won't see their messages, and they can't interact with you.", "Block")
+ ModerationAction.MUTE ->
+ Triple("Mute $label?", "Their messages will be hidden from your feed.", "Mute")
+ ModerationAction.REPORT ->
+ Triple("Report $label", "Why are you reporting this user?", "Report")
+ }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = Modifier.testTag(ModerationDialogTags.DIALOG),
+ title = { Text(title) },
+ text = {
+ Column {
+ Text(
+ text = body,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ if (isReport) {
+ Spacer(Modifier.height(8.dp))
+ ReportReason.entries.forEach { option ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .selectable(
+ selected = reason == option,
+ onClick = { reason = option },
+ )
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(selected = reason == option, onClick = { reason = option })
+ Text(option.label, style = MaterialTheme.typography.bodyMedium)
+ }
+ }
+ Spacer(Modifier.height(8.dp))
+ OutlinedTextField(
+ value = detail,
+ onValueChange = { detail = it },
+ placeholder = { Text("Add details (optional)") },
+ enabled = !isSubmitting,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(ModerationDialogTags.DETAIL),
+ )
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { onConfirm(reason, detail) },
+ enabled = !isSubmitting && (!isReport || reason != null),
+ modifier = Modifier.testTag(ModerationDialogTags.CONFIRM),
+ ) {
+ Text(confirmLabel)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss, enabled = !isSubmitting) { Text("Cancel") }
+ },
+ )
+}
diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt
index 2f21587..3c9b6a0 100644
--- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt
+++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt
@@ -38,8 +38,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme
import com.interlinedlist.android.feature.messages.domain.Message
import com.interlinedlist.android.feature.messages.domain.ReportReason
+import com.interlinedlist.android.feature.messages.ui.components.EditMessageSheet
import com.interlinedlist.android.feature.messages.ui.components.MessageCard
+import com.interlinedlist.android.feature.messages.ui.components.ModerationDialog
import com.interlinedlist.android.feature.messages.ui.components.ReportDialog
+import com.interlinedlist.android.feature.messages.ui.feed.ModerationAction
/** Stable test tags for the detail screen. */
object MessageDetailTags {
@@ -76,9 +79,18 @@ fun MessageDetailRoute(
onPostReply = viewModel::postReply,
onRetry = viewModel::load,
onReport = viewModel::openReport,
+ onEdit = viewModel::openEdit,
+ onBlockUser = { viewModel.openModeration(it, ModerationAction.BLOCK) },
+ onMuteUser = { viewModel.openModeration(it, ModerationAction.MUTE) },
+ onReportUser = { viewModel.openModeration(it, ModerationAction.REPORT) },
onFetchMetadata = { viewModel.onFetchMetadata() },
onDismissReport = viewModel::dismissReport,
onSubmitReport = viewModel::submitReport,
+ onEditTextChange = viewModel::onEditTextChange,
+ onDismissEdit = viewModel::dismissEdit,
+ onSaveEdit = viewModel::saveEdit,
+ onDismissModeration = viewModel::dismissModeration,
+ onConfirmModeration = viewModel::confirmModeration,
modifier = modifier,
)
}
@@ -96,9 +108,18 @@ fun MessageDetailScreen(
onRetry: () -> Unit,
modifier: Modifier = Modifier,
onReport: (Message) -> Unit = {},
+ onEdit: (Message) -> Unit = {},
+ onBlockUser: (Message) -> Unit = {},
+ onMuteUser: (Message) -> Unit = {},
+ onReportUser: (Message) -> Unit = {},
onFetchMetadata: (Message) -> Unit = {},
onDismissReport: () -> Unit = {},
onSubmitReport: (ReportReason, String) -> Unit = { _, _ -> },
+ onEditTextChange: (String) -> Unit = {},
+ onDismissEdit: () -> Unit = {},
+ onSaveEdit: () -> Unit = {},
+ onDismissModeration: () -> Unit = {},
+ onConfirmModeration: (ReportReason?, String) -> Unit = { _, _ -> },
) {
Scaffold(
modifier = modifier
@@ -128,6 +149,10 @@ fun MessageDetailScreen(
onReplyTextChange = onReplyTextChange,
onPostReply = onPostReply,
onReport = onReport,
+ onEdit = onEdit,
+ onBlockUser = onBlockUser,
+ onMuteUser = onMuteUser,
+ onReportUser = onReportUser,
onFetchMetadata = onFetchMetadata,
)
}
@@ -140,6 +165,26 @@ fun MessageDetailScreen(
isSubmitting = state.isReporting,
)
}
+
+ if (state.editTarget != null) {
+ EditMessageSheet(
+ text = state.editText,
+ canSave = state.canSaveEdit,
+ isSaving = state.isSavingEdit,
+ onTextChange = onEditTextChange,
+ onDismiss = onDismissEdit,
+ onSave = onSaveEdit,
+ )
+ }
+
+ state.moderationTarget?.let { target ->
+ ModerationDialog(
+ target = target,
+ isSubmitting = state.isModerating,
+ onDismiss = onDismissModeration,
+ onConfirm = onConfirmModeration,
+ )
+ }
}
@Composable
@@ -151,6 +196,10 @@ private fun Content(
onReplyTextChange: (String) -> Unit,
onPostReply: () -> Unit,
onReport: (Message) -> Unit,
+ onEdit: (Message) -> Unit,
+ onBlockUser: (Message) -> Unit,
+ onMuteUser: (Message) -> Unit,
+ onReportUser: (Message) -> Unit,
onFetchMetadata: (Message) -> Unit,
) {
val message = state.message
@@ -172,6 +221,10 @@ private fun Content(
onDig = onDig,
onDelete = {},
onReport = { onReport(message) },
+ onEdit = { onEdit(message) },
+ onBlockUser = { onBlockUser(message) },
+ onMuteUser = { onMuteUser(message) },
+ onReportUser = { onReportUser(message) },
onOpenLink = { onFetchMetadata(message) },
)
HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.outlineVariant)
@@ -189,6 +242,10 @@ private fun Content(
onDig = {},
onDelete = {},
onReport = { onReport(reply) },
+ onEdit = { onEdit(reply) },
+ onBlockUser = { onBlockUser(reply) },
+ onMuteUser = { onMuteUser(reply) },
+ onReportUser = { onReportUser(reply) },
onOpenLink = { onFetchMetadata(reply) },
)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModel.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModel.kt
index 615805c..a11a22c 100644
--- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModel.kt
+++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModel.kt
@@ -8,6 +8,8 @@ import com.interlinedlist.android.core.common.result.AppError
import com.interlinedlist.android.feature.messages.data.MessagesRepository
import com.interlinedlist.android.feature.messages.domain.Message
import com.interlinedlist.android.feature.messages.domain.ReportReason
+import com.interlinedlist.android.feature.messages.ui.feed.ModerationAction
+import com.interlinedlist.android.feature.messages.ui.feed.ModerationTarget
import com.interlinedlist.android.feature.messages.ui.isSubscriptionGate
import com.interlinedlist.android.feature.messages.ui.toUserMessage
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -35,8 +37,16 @@ data class MessageDetailUiState(
/** The message (root or a reply) being reported, if any. */
val reportTarget: Message? = null,
val isReporting: Boolean = false,
+ /** The message (root or a reply) being edited in-place, if any. */
+ val editTarget: Message? = null,
+ val editText: String = "",
+ val isSavingEdit: Boolean = false,
+ /** A pending author-moderation action awaiting confirmation, if any. */
+ val moderationTarget: ModerationTarget? = null,
+ val isModerating: Boolean = false,
) {
val canReply: Boolean get() = replyText.isNotBlank() && !isPostingReply
+ val canSaveEdit: Boolean get() = editText.isNotBlank() && !isSavingEdit
}
private data class DetailTransientState(
@@ -47,6 +57,11 @@ private data class DetailTransientState(
val isPostingReply: Boolean = false,
val reportTarget: Message? = null,
val isReporting: Boolean = false,
+ val editTarget: Message? = null,
+ val editText: String = "",
+ val isSavingEdit: Boolean = false,
+ val moderationTarget: ModerationTarget? = null,
+ val isModerating: Boolean = false,
)
@HiltViewModel
@@ -77,6 +92,11 @@ class MessageDetailViewModel @Inject constructor(
isPostingReply = t.isPostingReply,
reportTarget = t.reportTarget,
isReporting = t.isReporting,
+ editTarget = t.editTarget,
+ editText = t.editText,
+ isSavingEdit = t.isSavingEdit,
+ moderationTarget = t.moderationTarget,
+ isModerating = t.isModerating,
)
}.stateIn(
scope = viewModelScope,
@@ -152,6 +172,66 @@ class MessageDetailViewModel @Inject constructor(
}
}
+ // --- edit own message --------------------------------------------------
+
+ fun openEdit(message: Message) = transient.update {
+ it.copy(editTarget = message, editText = message.content, errorMessage = null)
+ }
+
+ fun onEditTextChange(value: String) = transient.update { it.copy(editText = value) }
+
+ fun dismissEdit() = transient.update {
+ it.copy(editTarget = null, editText = "", isSavingEdit = false)
+ }
+
+ fun saveEdit() {
+ val target = transient.value.editTarget ?: return
+ val text = transient.value.editText.trim()
+ if (text.isBlank()) return
+ transient.update { it.copy(isSavingEdit = true, errorMessage = null) }
+ viewModelScope.launch {
+ when (val result = repository.editMessage(target.id, text)) {
+ is ApiResult.Success -> transient.update {
+ it.copy(isSavingEdit = false, editTarget = null, editText = "")
+ }
+ is ApiResult.Failure -> transient.update {
+ it.copy(isSavingEdit = false).withError(result.error)
+ }
+ }
+ }
+ }
+
+ // --- author moderation -------------------------------------------------
+
+ fun openModeration(message: Message, action: ModerationAction) = transient.update {
+ it.copy(moderationTarget = ModerationTarget(message, action), errorMessage = null)
+ }
+
+ fun dismissModeration() = transient.update {
+ it.copy(moderationTarget = null, isModerating = false)
+ }
+
+ fun confirmModeration(reason: ReportReason? = null, detail: String = "") {
+ val target = transient.value.moderationTarget ?: return
+ transient.update { it.copy(isModerating = true, errorMessage = null) }
+ viewModelScope.launch {
+ val result = when (target.action) {
+ ModerationAction.BLOCK -> repository.blockUser(target.username)
+ ModerationAction.MUTE -> repository.muteUser(target.username)
+ ModerationAction.REPORT ->
+ repository.reportUser(target.username, reason ?: ReportReason.OTHER, detail)
+ }
+ when (result) {
+ is ApiResult.Success -> transient.update {
+ it.copy(isModerating = false, moderationTarget = null)
+ }
+ is ApiResult.Failure -> transient.update {
+ it.copy(isModerating = false, moderationTarget = null).withError(result.error)
+ }
+ }
+ }
+ }
+
// --- link metadata -----------------------------------------------------
/** Fetches link-preview metadata for the current message; cache re-emits it. */
diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt
index f5feb47..523a1c5 100644
--- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt
+++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt
@@ -13,8 +13,11 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -24,10 +27,12 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Videocam
+import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -36,6 +41,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -54,9 +60,13 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme
+import com.interlinedlist.android.feature.messages.domain.CrossPostStatus
+import com.interlinedlist.android.feature.messages.domain.LinkedNetwork
import com.interlinedlist.android.feature.messages.domain.Message
import com.interlinedlist.android.feature.messages.domain.ReportReason
+import com.interlinedlist.android.feature.messages.ui.components.EditMessageSheet
import com.interlinedlist.android.feature.messages.ui.components.MessageCard
+import com.interlinedlist.android.feature.messages.ui.components.ModerationDialog
import com.interlinedlist.android.feature.messages.ui.components.ReportDialog
import com.interlinedlist.android.feature.messages.ui.readMediaBytes
import java.time.Instant
@@ -76,6 +86,17 @@ object MessagesFeedTags {
const val COMPOSE_ADD_VIDEO = "messagesComposeAddVideo"
const val COMPOSE_SCHEDULE = "messagesComposeSchedule"
const val SCHEDULED_ACTION = "messagesFeedScheduledAction"
+
+ /** The always-on InterlinedList destination chip. */
+ const val DESTINATION_IL = "messagesComposeDestinationInterlinedList"
+ /** Prefix for a per-network destination chip; suffixed with the network id. */
+ const val DESTINATION_PREFIX = "messagesComposeDestination_"
+ /** Hint shown when the account has no linked networks to cross-post to. */
+ const val DESTINATIONS_HINT = "messagesComposeDestinationsHint"
+ /** Post-send banner listing per-network cross-post statuses. */
+ const val CROSS_POST_STATUS = "messagesFeedCrossPostStatus"
+
+ fun destinationTag(networkId: String): String = DESTINATION_PREFIX + networkId
}
/**
@@ -102,6 +123,10 @@ fun MessagesRoute(
onDig = viewModel::onDig,
onDelete = viewModel::onDelete,
onReport = viewModel::openReport,
+ onEdit = viewModel::openEdit,
+ onBlockUser = { viewModel.openModeration(it, ModerationAction.BLOCK) },
+ onMuteUser = { viewModel.openModeration(it, ModerationAction.MUTE) },
+ onReportUser = { viewModel.openModeration(it, ModerationAction.REPORT) },
onFetchMetadata = viewModel::onFetchMetadata,
onOpenCompose = viewModel::openCompose,
onDismissCompose = viewModel::dismissCompose,
@@ -116,8 +141,15 @@ fun MessagesRoute(
},
onRemoveAttachment = viewModel::onRemoveAttachment,
onScheduleChange = viewModel::onScheduleChange,
+ onToggleNetwork = viewModel::onToggleNetwork,
+ onDismissCrossPostStatuses = viewModel::dismissCrossPostStatuses,
onDismissReport = viewModel::dismissReport,
onSubmitReport = viewModel::submitReport,
+ onEditTextChange = viewModel::onEditTextChange,
+ onDismissEdit = viewModel::dismissEdit,
+ onSaveEdit = viewModel::saveEdit,
+ onDismissModeration = viewModel::dismissModeration,
+ onConfirmModeration = viewModel::confirmModeration,
modifier = modifier,
)
}
@@ -139,12 +171,23 @@ fun MessagesFeedScreen(
modifier: Modifier = Modifier,
onOpenScheduled: () -> Unit = {},
onReport: (Message) -> Unit = {},
+ onEdit: (Message) -> Unit = {},
+ onBlockUser: (Message) -> Unit = {},
+ onMuteUser: (Message) -> Unit = {},
+ onReportUser: (Message) -> Unit = {},
onFetchMetadata: (Message) -> Unit = {},
onAttachMedia: (Uri, Boolean) -> Unit = { _, _ -> },
onRemoveAttachment: (PendingAttachment) -> Unit = {},
onScheduleChange: (String?) -> Unit = {},
+ onToggleNetwork: (String) -> Unit = {},
+ onDismissCrossPostStatuses: () -> Unit = {},
onDismissReport: () -> Unit = {},
onSubmitReport: (ReportReason, String) -> Unit = { _, _ -> },
+ onEditTextChange: (String) -> Unit = {},
+ onDismissEdit: () -> Unit = {},
+ onSaveEdit: () -> Unit = {},
+ onDismissModeration: () -> Unit = {},
+ onConfirmModeration: (ReportReason?, String) -> Unit = { _, _ -> },
) {
Scaffold(
modifier = modifier.fillMaxSize(),
@@ -186,6 +229,10 @@ fun MessagesFeedScreen(
onDig = onDig,
onDelete = onDelete,
onReport = onReport,
+ onEdit = onEdit,
+ onBlockUser = onBlockUser,
+ onMuteUser = onMuteUser,
+ onReportUser = onReportUser,
onFetchMetadata = onFetchMetadata,
)
}
@@ -200,6 +247,14 @@ fun MessagesFeedScreen(
onAttachMedia = onAttachMedia,
onRemoveAttachment = onRemoveAttachment,
onScheduleChange = onScheduleChange,
+ onToggleNetwork = onToggleNetwork,
+ )
+ }
+
+ if (state.crossPostStatuses.isNotEmpty()) {
+ CrossPostStatusBanner(
+ statuses = state.crossPostStatuses,
+ onDismiss = onDismissCrossPostStatuses,
)
}
@@ -210,6 +265,26 @@ fun MessagesFeedScreen(
isSubmitting = state.isReporting,
)
}
+
+ if (state.editTarget != null) {
+ EditMessageSheet(
+ text = state.editText,
+ canSave = state.canSaveEdit,
+ isSaving = state.isSavingEdit,
+ onTextChange = onEditTextChange,
+ onDismiss = onDismissEdit,
+ onSave = onSaveEdit,
+ )
+ }
+
+ state.moderationTarget?.let { target ->
+ ModerationDialog(
+ target = target,
+ isSubmitting = state.isModerating,
+ onDismiss = onDismissModeration,
+ onConfirm = onConfirmModeration,
+ )
+ }
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -223,6 +298,10 @@ private fun FeedContent(
onDig: (Message) -> Unit,
onDelete: (Message) -> Unit,
onReport: (Message) -> Unit,
+ onEdit: (Message) -> Unit,
+ onBlockUser: (Message) -> Unit,
+ onMuteUser: (Message) -> Unit,
+ onReportUser: (Message) -> Unit,
onFetchMetadata: (Message) -> Unit,
) {
PullToRefreshBox(
@@ -243,6 +322,10 @@ private fun FeedContent(
onDig = onDig,
onDelete = onDelete,
onReport = onReport,
+ onEdit = onEdit,
+ onBlockUser = onBlockUser,
+ onMuteUser = onMuteUser,
+ onReportUser = onReportUser,
onFetchMetadata = onFetchMetadata,
)
}
@@ -257,6 +340,10 @@ private fun FeedList(
onDig: (Message) -> Unit,
onDelete: (Message) -> Unit,
onReport: (Message) -> Unit,
+ onEdit: (Message) -> Unit,
+ onBlockUser: (Message) -> Unit,
+ onMuteUser: (Message) -> Unit,
+ onReportUser: (Message) -> Unit,
onFetchMetadata: (Message) -> Unit,
) {
val listState = rememberLazyListState()
@@ -282,6 +369,10 @@ private fun FeedList(
onDig = { onDig(message) },
onDelete = { onDelete(message) },
onReport = { onReport(message) },
+ onEdit = { onEdit(message) },
+ onBlockUser = { onBlockUser(message) },
+ onMuteUser = { onMuteUser(message) },
+ onReportUser = { onReportUser(message) },
onOpenLink = { onFetchMetadata(message) },
)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
@@ -367,6 +458,7 @@ private fun ComposeSheet(
onAttachMedia: (Uri, Boolean) -> Unit,
onRemoveAttachment: (PendingAttachment) -> Unit,
onScheduleChange: (String?) -> Unit,
+ onToggleNetwork: (String) -> Unit,
) {
val imagePicker = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent(),
@@ -426,6 +518,14 @@ private fun ComposeSheet(
)
}
+ Spacer(Modifier.height(12.dp))
+ DestinationsRow(
+ networks = state.linkedNetworks,
+ selectedIds = state.selectedNetworkIds,
+ enabled = !state.isPosting,
+ onToggleNetwork = onToggleNetwork,
+ )
+
Spacer(Modifier.height(12.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onDismiss, enabled = !state.isPosting) { Text("Cancel") }
@@ -489,6 +589,137 @@ private fun AttachmentRow(
}
}
+/**
+ * The cross-post destinations row: InterlinedList is always-on (rendered as a
+ * disabled, always-selected chip), followed by a toggle chip per already-linked
+ * network. When nothing is linked, a subtle hint points the user to the web to
+ * link accounts — the app does not build an OAuth connect flow.
+ */
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@Composable
+private fun DestinationsRow(
+ networks: List,
+ selectedIds: Set,
+ enabled: Boolean,
+ onToggleNetwork: (String) -> Unit,
+) {
+ Column {
+ Text(
+ text = "Post to",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(6.dp))
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ // InterlinedList is always a destination; shown selected and locked.
+ FilterChip(
+ selected = true,
+ onClick = {},
+ enabled = false,
+ label = { Text("InterlinedList") },
+ leadingIcon = {
+ Icon(Icons.Filled.Check, contentDescription = null, modifier = Modifier.size(16.dp))
+ },
+ modifier = Modifier.testTag(MessagesFeedTags.DESTINATION_IL),
+ )
+ networks.forEach { network ->
+ val isSelected = network.id in selectedIds
+ FilterChip(
+ selected = isSelected,
+ onClick = { onToggleNetwork(network.id) },
+ enabled = enabled,
+ label = { Text(network.chipLabel) },
+ leadingIcon = if (isSelected) {
+ {
+ Icon(
+ Icons.Filled.Check,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ )
+ }
+ } else {
+ null
+ },
+ modifier = Modifier.testTag(MessagesFeedTags.destinationTag(network.id)),
+ )
+ }
+ }
+ if (networks.isEmpty()) {
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = "Link accounts on the web to cross-post.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.testTag(MessagesFeedTags.DESTINATIONS_HINT),
+ )
+ }
+ }
+}
+
+/**
+ * A brief, dismissible banner surfacing the per-network cross-post statuses
+ * returned by the create endpoint. Anchored to the bottom of the screen.
+ */
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun CrossPostStatusBanner(
+ statuses: List,
+ onDismiss: () -> Unit,
+) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) {
+ Surface(
+ tonalElevation = 3.dp,
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(16.dp)
+ .testTag(MessagesFeedTags.CROSS_POST_STATUS),
+ ) {
+ Column(Modifier.padding(16.dp)) {
+ Text(
+ text = "Cross-post results",
+ style = MaterialTheme.typography.titleSmall,
+ )
+ Spacer(Modifier.height(8.dp))
+ statuses.forEach { status ->
+ Row(
+ Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ val icon = if (status.isFailed) Icons.Filled.Close else Icons.Filled.Check
+ val tint = when {
+ status.isFailed -> MaterialTheme.colorScheme.error
+ status.isSuccess -> MaterialTheme.colorScheme.primary
+ else -> MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(16.dp))
+ Text(
+ text = buildString {
+ append(status.label)
+ append(": ")
+ append(
+ when {
+ status.isFailed -> status.error ?: "Failed"
+ status.isSuccess -> "Posted"
+ else -> status.status
+ },
+ )
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ }
+ Spacer(Modifier.height(4.dp))
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+ TextButton(onClick = onDismiss) { Text("Dismiss") }
+ }
+ }
+ }
+ }
+}
+
/**
* Toggle chip for scheduling. To stay device- and dialog-independent (and easily
* testable), tapping sets a fixed "1 hour from now" ISO time; tapping again clears
diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt
index f0b2e05..09d6af8 100644
--- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt
+++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt
@@ -5,6 +5,9 @@ import androidx.lifecycle.viewModelScope
import com.interlinedlist.android.core.common.result.ApiResult
import com.interlinedlist.android.core.common.result.AppError
import com.interlinedlist.android.feature.messages.data.MessagesRepository
+import com.interlinedlist.android.feature.messages.domain.CrossPostSelection
+import com.interlinedlist.android.feature.messages.domain.CrossPostStatus
+import com.interlinedlist.android.feature.messages.domain.LinkedNetwork
import com.interlinedlist.android.feature.messages.domain.Message
import com.interlinedlist.android.feature.messages.domain.ReportReason
import com.interlinedlist.android.feature.messages.ui.isSubscriptionGate
@@ -44,9 +47,23 @@ data class MessagesFeedUiState(
val attachments: List = emptyList(),
/** Optional future send time (ISO-8601) for the in-progress compose. */
val scheduledAt: String? = null,
+ /** The caller's already-linked networks, offered as cross-post destinations. */
+ val linkedNetworks: List = emptyList(),
+ /** Ids of the linked networks currently selected as cross-post targets. */
+ val selectedNetworkIds: Set = emptySet(),
+ /** Per-network delivery statuses from the last successful post, if any. */
+ val crossPostStatuses: List = emptyList(),
/** The message currently being reported (drives the report dialog), if any. */
val reportTarget: Message? = null,
val isReporting: Boolean = false,
+ /** The message currently being edited in-place (drives the edit sheet), if any. */
+ val editTarget: Message? = null,
+ /** The working edit text seeded from [editTarget]'s content. */
+ val editText: String = "",
+ val isSavingEdit: Boolean = false,
+ /** A pending author-moderation action awaiting confirmation, if any. */
+ val moderationTarget: ModerationTarget? = null,
+ val isModerating: Boolean = false,
) {
val isEmpty: Boolean get() = messages.isEmpty()
val hasAttachments: Boolean get() = attachments.isNotEmpty()
@@ -55,8 +72,29 @@ data class MessagesFeedUiState(
val canPost: Boolean
get() = (composeText.isNotBlank() || attachments.any { it.hostedUrl != null }) &&
!isPosting && !isUploading
+
+ /** True when the account has no linked networks to cross-post to. */
+ val hasNoLinkedNetworks: Boolean get() = linkedNetworks.isEmpty()
+
+ /** Edit can be saved when the text is non-blank and not currently saving. */
+ val canSaveEdit: Boolean get() = editText.isNotBlank() && !isSavingEdit
+}
+
+/**
+ * A pending author-moderation action, captured when the user taps Block / Mute /
+ * Report on someone else's message. Drives a confirm dialog before the call runs.
+ */
+data class ModerationTarget(
+ val message: Message,
+ val action: ModerationAction,
+) {
+ val username: String get() = message.authorUsername
+ val authorLabel: String get() = message.authorLabel
}
+/** The three author-level moderation actions (distinct from reporting a message). */
+enum class ModerationAction { BLOCK, MUTE, REPORT }
+
/** Transient (non-cached) UI flags kept separate from the Room-backed message list. */
private data class FeedTransientState(
val isRefreshing: Boolean = false,
@@ -69,8 +107,16 @@ private data class FeedTransientState(
val isPosting: Boolean = false,
val attachments: List = emptyList(),
val scheduledAt: String? = null,
+ val linkedNetworks: List = emptyList(),
+ val selectedNetworkIds: Set = emptySet(),
+ val crossPostStatuses: List = emptyList(),
val reportTarget: Message? = null,
val isReporting: Boolean = false,
+ val editTarget: Message? = null,
+ val editText: String = "",
+ val isSavingEdit: Boolean = false,
+ val moderationTarget: ModerationTarget? = null,
+ val isModerating: Boolean = false,
)
@HiltViewModel
@@ -98,8 +144,16 @@ class MessagesFeedViewModel @Inject constructor(
isPosting = t.isPosting,
attachments = t.attachments,
scheduledAt = t.scheduledAt,
+ linkedNetworks = t.linkedNetworks,
+ selectedNetworkIds = t.selectedNetworkIds,
+ crossPostStatuses = t.crossPostStatuses,
reportTarget = t.reportTarget,
isReporting = t.isReporting,
+ editTarget = t.editTarget,
+ editText = t.editText,
+ isSavingEdit = t.isSavingEdit,
+ moderationTarget = t.moderationTarget,
+ isModerating = t.isModerating,
)
}.stateIn(
scope = viewModelScope,
@@ -109,6 +163,29 @@ class MessagesFeedViewModel @Inject constructor(
init {
refresh()
+ loadLinkedNetworks()
+ }
+
+ /**
+ * Loads the caller's already-linked networks so the composer can offer them as
+ * cross-post destinations. Best-effort: a failure just leaves the list empty
+ * (the composer then shows the "link accounts on the web" hint) and does not
+ * surface a feed-level error.
+ */
+ fun loadLinkedNetworks() {
+ viewModelScope.launch {
+ when (val result = repository.getLinkedNetworks()) {
+ is ApiResult.Success -> transient.update { state ->
+ // Prune any selections whose network is no longer linked.
+ val liveIds = result.data.map { it.id }.toSet()
+ state.copy(
+ linkedNetworks = result.data,
+ selectedNetworkIds = state.selectedNetworkIds.intersect(liveIds),
+ )
+ }
+ is ApiResult.Failure -> Unit
+ }
+ }
}
fun refresh() {
@@ -162,14 +239,36 @@ class MessagesFeedViewModel @Inject constructor(
// --- compose sheet -----------------------------------------------------
- fun openCompose() = transient.update { it.copy(isComposeOpen = true, errorMessage = null) }
+ fun openCompose() = transient.update {
+ it.copy(isComposeOpen = true, errorMessage = null, crossPostStatuses = emptyList())
+ }
fun dismissCompose() = transient.update {
- it.copy(isComposeOpen = false, composeText = "", attachments = emptyList(), scheduledAt = null)
+ it.copy(
+ isComposeOpen = false,
+ composeText = "",
+ attachments = emptyList(),
+ scheduledAt = null,
+ selectedNetworkIds = emptySet(),
+ )
}
fun onComposeTextChange(value: String) = transient.update { it.copy(composeText = value) }
+ /**
+ * Toggles a linked network as a cross-post target for the in-progress compose.
+ * No-ops for an id that isn't currently linked.
+ */
+ fun onToggleNetwork(networkId: String) = transient.update { state ->
+ if (state.linkedNetworks.none { it.id == networkId }) return@update state
+ val selected = if (networkId in state.selectedNetworkIds) {
+ state.selectedNetworkIds - networkId
+ } else {
+ state.selectedNetworkIds + networkId
+ }
+ state.copy(selectedNetworkIds = selected)
+ }
+
/** Sets (or clears with null) the future send time for the in-progress compose. */
fun onScheduleChange(isoTimestamp: String?) = transient.update { it.copy(scheduledAt = isoTimestamp) }
@@ -224,7 +323,10 @@ class MessagesFeedViewModel @Inject constructor(
if (snapshot.attachments.any { it.isUploading }) return
val images = snapshot.attachments.filterNot { it.isVideo }.mapNotNull { it.hostedUrl }
val videos = snapshot.attachments.filter { it.isVideo }.mapNotNull { it.hostedUrl }
- transient.update { it.copy(isPosting = true, errorMessage = null) }
+ // Fold the selected linked networks into the cross-post request fields.
+ val selected = snapshot.linkedNetworks.filter { it.id in snapshot.selectedNetworkIds }
+ val crossPost = CrossPostSelection.from(selected)
+ transient.update { it.copy(isPosting = true, errorMessage = null, crossPostStatuses = emptyList()) }
viewModelScope.launch {
when (
val result = repository.createMessage(
@@ -232,6 +334,7 @@ class MessagesFeedViewModel @Inject constructor(
imageUrls = images,
videoUrls = videos,
scheduledAt = snapshot.scheduledAt,
+ crossPost = crossPost,
)
) {
is ApiResult.Success -> transient.update {
@@ -241,6 +344,8 @@ class MessagesFeedViewModel @Inject constructor(
composeText = "",
attachments = emptyList(),
scheduledAt = null,
+ selectedNetworkIds = emptySet(),
+ crossPostStatuses = result.data.crossPosts,
)
}
is ApiResult.Failure -> transient.update {
@@ -250,6 +355,9 @@ class MessagesFeedViewModel @Inject constructor(
}
}
+ /** Dismisses the post-send cross-post status banner. */
+ fun dismissCrossPostStatuses() = transient.update { it.copy(crossPostStatuses = emptyList()) }
+
// --- report ------------------------------------------------------------
fun openReport(message: Message) = transient.update { it.copy(reportTarget = message, errorMessage = null) }
@@ -271,6 +379,74 @@ class MessagesFeedViewModel @Inject constructor(
}
}
+ // --- edit own message --------------------------------------------------
+
+ /** Opens the in-place editor seeded with [message]'s current content. */
+ fun openEdit(message: Message) = transient.update {
+ it.copy(editTarget = message, editText = message.content, errorMessage = null)
+ }
+
+ fun onEditTextChange(value: String) = transient.update { it.copy(editText = value) }
+
+ fun dismissEdit() = transient.update {
+ it.copy(editTarget = null, editText = "", isSavingEdit = false)
+ }
+
+ /** Saves the edit: PATCHes the new content (repository updates the cache). */
+ fun saveEdit() {
+ val target = transient.value.editTarget ?: return
+ val text = transient.value.editText.trim()
+ if (text.isBlank()) return
+ transient.update { it.copy(isSavingEdit = true, errorMessage = null) }
+ viewModelScope.launch {
+ when (val result = repository.editMessage(target.id, text)) {
+ is ApiResult.Success -> transient.update {
+ it.copy(isSavingEdit = false, editTarget = null, editText = "")
+ }
+ is ApiResult.Failure -> transient.update {
+ it.copy(isSavingEdit = false).withError(result.error)
+ }
+ }
+ }
+ }
+
+ // --- author moderation -------------------------------------------------
+
+ /** Queues a Block / Mute / Report-user action for confirmation. */
+ fun openModeration(message: Message, action: ModerationAction) = transient.update {
+ it.copy(moderationTarget = ModerationTarget(message, action), errorMessage = null)
+ }
+
+ fun dismissModeration() = transient.update {
+ it.copy(moderationTarget = null, isModerating = false)
+ }
+
+ /**
+ * Confirms the queued moderation action. Block/Mute additionally hide the
+ * author's messages from the local feed (handled in the repository); Report
+ * carries the chosen [reason] and optional [detail].
+ */
+ fun confirmModeration(reason: ReportReason? = null, detail: String = "") {
+ val target = transient.value.moderationTarget ?: return
+ transient.update { it.copy(isModerating = true, errorMessage = null) }
+ viewModelScope.launch {
+ val result = when (target.action) {
+ ModerationAction.BLOCK -> repository.blockUser(target.username)
+ ModerationAction.MUTE -> repository.muteUser(target.username)
+ ModerationAction.REPORT ->
+ repository.reportUser(target.username, reason ?: ReportReason.OTHER, detail)
+ }
+ when (result) {
+ is ApiResult.Success -> transient.update {
+ it.copy(isModerating = false, moderationTarget = null)
+ }
+ is ApiResult.Failure -> transient.update {
+ it.copy(isModerating = false, moderationTarget = null).withError(result.error)
+ }
+ }
+ }
+ }
+
// --- link metadata -----------------------------------------------------
/** Fetches link-preview metadata for a message; the cache Flow re-emits it. */
diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt
index 17aa29d..75dc8c5 100644
--- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt
+++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt
@@ -4,6 +4,8 @@ import com.google.common.truth.Truth.assertThat
import com.interlinedlist.android.core.common.result.ApiResult
import com.interlinedlist.android.core.common.result.AppError
import com.interlinedlist.android.feature.messages.data.remote.MessagesApi
+import com.interlinedlist.android.feature.messages.domain.CrossPostSelection
+import com.interlinedlist.android.feature.messages.domain.NetworkProvider
import com.interlinedlist.android.feature.messages.domain.ReportReason
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -23,7 +25,13 @@ import retrofit2.Retrofit
class DefaultMessagesRepositoryTest {
private val dispatcher = StandardTestDispatcher()
- private val json = Json { ignoreUnknownKeys = true; explicitNulls = false }
+ // Mirrors the production Json (see core:network NetworkModule): coerce explicit
+ // nulls (e.g. `crossPosts: null`) to the property's default rather than failing.
+ private val json = Json {
+ ignoreUnknownKeys = true
+ explicitNulls = false
+ coerceInputValues = true
+ }
private lateinit var server: MockWebServer
private lateinit var api: MessagesApi
@@ -143,7 +151,7 @@ class DefaultMessagesRepositoryTest {
val result = repo.createMessage("brand new")
- assertThat((result as ApiResult.Success).data.id).isEqualTo("new")
+ assertThat((result as ApiResult.Success).data.message.id).isEqualTo("new")
val ids = repo.observeFeed().first().map { it.id }
assertThat(ids.first()).isEqualTo("new")
}
@@ -289,7 +297,7 @@ class DefaultMessagesRepositoryTest {
imageUrls = listOf("https://cdn/a.png"),
)
- assertThat((result as ApiResult.Success).data.imageUrls).containsExactly("https://cdn/a.png")
+ assertThat((result as ApiResult.Success).data.message.imageUrls).containsExactly("https://cdn/a.png")
val body = server.takeRequest().body.readUtf8()
assertThat(body).contains("https://cdn/a.png")
assertThat(body).contains("imageUrls")
@@ -307,7 +315,7 @@ class DefaultMessagesRepositoryTest {
val result = repo.createMessage(content = "later", scheduledAt = "2026-07-19T09:00:00Z")
- assertThat((result as ApiResult.Success).data.scheduledAt).isEqualTo("2026-07-19T09:00:00Z")
+ assertThat((result as ApiResult.Success).data.message.scheduledAt).isEqualTo("2026-07-19T09:00:00Z")
assertThat(repo.observeFeed().first()).isEmpty()
assertThat(repo.observeScheduled().first().map { it.id }).containsExactly("sch1")
}
@@ -369,6 +377,276 @@ class DefaultMessagesRepositoryTest {
assertThat(body).doesNotContain("detail")
}
+ @Test
+ fun `editMessage PATCHes the content and updates the cached message`() = runTest(dispatcher) {
+ enqueueJson(
+ 200,
+ """{ "data": [ { "id": "1", "content": "original", "author": { "id": "me", "username": "me" } } ],
+ "pagination": { "hasMore": false } }""",
+ )
+ enqueueJson(200, "") // PATCH response body is not modelled; a 2xx is success.
+ val repo = repository()
+ repo.refreshFeed()
+
+ val result = repo.editMessage("1", content = "edited body")
+
+ assertThat(result).isInstanceOf(ApiResult.Success::class.java)
+ assertThat((result as ApiResult.Success).data.content).isEqualTo("edited body")
+ // The cache reflects the new content and now carries an "edited" marker.
+ val cached = repo.observeMessage("1").first()
+ assertThat(cached?.content).isEqualTo("edited body")
+ assertThat(cached?.isEdited).isTrue()
+
+ server.takeRequest() // the refresh GET
+ val patch = server.takeRequest()
+ assertThat(patch.method).isEqualTo("PATCH")
+ assertThat(patch.path).contains("api/messages/1")
+ assertThat(patch.body.readUtf8()).contains("\"content\":\"edited body\"")
+ }
+
+ @Test
+ fun `editMessage rolls back the cached content on failure`() = runTest(dispatcher) {
+ enqueueJson(
+ 200,
+ """{ "data": [ { "id": "1", "content": "original", "author": { "id": "me", "username": "me" } } ],
+ "pagination": { "hasMore": false } }""",
+ )
+ enqueueJson(500, """{ "error": "boom" }""")
+ val repo = repository()
+ repo.refreshFeed()
+
+ val result = repo.editMessage("1", content = "will not stick")
+
+ assertThat(result).isInstanceOf(ApiResult.Failure::class.java)
+ val cached = repo.observeMessage("1").first()
+ assertThat(cached?.content).isEqualTo("original")
+ assertThat(cached?.isEdited).isFalse()
+ }
+
+ @Test
+ fun `blockUser posts and hides the author's messages from the feed`() = runTest(dispatcher) {
+ enqueueJson(
+ 200,
+ """{ "data": [
+ { "id": "1", "content": "by amy", "author": { "id": "a", "username": "amy" } },
+ { "id": "2", "content": "by bob", "author": { "id": "b", "username": "bob" } }
+ ], "pagination": { "hasMore": false } }""",
+ )
+ enqueueJson(201, "")
+ val repo = repository()
+ repo.refreshFeed()
+
+ val result = repo.blockUser("amy")
+
+ assertThat(result).isInstanceOf(ApiResult.Success::class.java)
+ val request = server.let { it.takeRequest(); it.takeRequest() }
+ assertThat(request.method).isEqualTo("POST")
+ assertThat(request.path).contains("api/users/amy/block")
+ // Amy's message is gone; bob's remains.
+ assertThat(repo.observeFeed().first().map { it.id }).containsExactly("2")
+ }
+
+ @Test
+ fun `muteUser posts and hides the author's messages from the feed`() = runTest(dispatcher) {
+ enqueueJson(
+ 200,
+ """{ "data": [
+ { "id": "1", "content": "by amy", "author": { "id": "a", "username": "amy" } }
+ ], "pagination": { "hasMore": false } }""",
+ )
+ enqueueJson(201, "")
+ val repo = repository()
+ repo.refreshFeed()
+
+ val result = repo.muteUser("amy")
+
+ assertThat(result).isInstanceOf(ApiResult.Success::class.java)
+ server.takeRequest()
+ assertThat(server.takeRequest().path).contains("api/users/amy/mute")
+ assertThat(repo.observeFeed().first()).isEmpty()
+ }
+
+ @Test
+ fun `blockUser failure leaves the feed intact`() = runTest(dispatcher) {
+ enqueueJson(
+ 200,
+ """{ "data": [
+ { "id": "1", "content": "by amy", "author": { "id": "a", "username": "amy" } }
+ ], "pagination": { "hasMore": false } }""",
+ )
+ enqueueJson(500, """{ "error": "boom" }""")
+ val repo = repository()
+ repo.refreshFeed()
+
+ val result = repo.blockUser("amy")
+
+ assertThat(result).isInstanceOf(ApiResult.Failure::class.java)
+ assertThat(repo.observeFeed().first().map { it.id }).containsExactly("1")
+ }
+
+ @Test
+ fun `reportUser posts the reason and detail to the user report endpoint`() = runTest(dispatcher) {
+ enqueueJson(201, "")
+ val repo = repository()
+
+ val result = repo.reportUser("amy", ReportReason.HARASSMENT, detail = "abusive dms")
+
+ assertThat(result).isInstanceOf(ApiResult.Success::class.java)
+ val request = server.takeRequest()
+ assertThat(request.path).contains("api/users/amy/report")
+ val body = request.body.readUtf8()
+ assertThat(body).contains("\"reason\":\"harassment\"")
+ assertThat(body).contains("abusive dms")
+ }
+
+ @Test
+ fun `reportUser omits blank detail`() = runTest(dispatcher) {
+ enqueueJson(201, "")
+ val repo = repository()
+
+ repo.reportUser("amy", ReportReason.OTHER, detail = " ")
+
+ val body = server.takeRequest().body.readUtf8()
+ assertThat(body).doesNotContain("detail")
+ }
+
+ // --- cross-posting -----------------------------------------------------
+
+ @Test
+ fun `getLinkedNetworks parses varied providers`() = runTest(dispatcher) {
+ enqueueJson(
+ 200,
+ """
+ { "identities": [
+ { "id": "m1", "provider": "mastodon:techhub.social",
+ "providerUsername": "crew@techhub.social",
+ "profileUrl": "https://techhub.social/@crew", "avatarUrl": null,
+ "connectedAt": "2026-04-07T16:35:32.476Z", "lastVerifiedAt": null },
+ { "id": "l1", "provider": "linkedin", "providerUsername": "Adron Hall" },
+ { "id": "t1", "provider": "twitter", "providerUsername": "interlinedlist" },
+ { "id": "b1", "provider": "bluesky", "providerUsername": "il.bsky.social" }
+ ] }
+ """.trimIndent(),
+ )
+ val repo = repository()
+
+ val result = repo.getLinkedNetworks()
+
+ val networks = (result as ApiResult.Success).data
+ assertThat(networks.map { it.id }).containsExactly("m1", "l1", "t1", "b1").inOrder()
+ assertThat(networks.map { it.networkProvider }).containsExactly(
+ NetworkProvider.MASTODON,
+ NetworkProvider.LINKEDIN,
+ NetworkProvider.TWITTER,
+ NetworkProvider.BLUESKY,
+ ).inOrder()
+ // The mastodon chip label surfaces the instance host.
+ assertThat(networks.first().chipLabel).isEqualTo("techhub.social")
+ assertThat(server.takeRequest().path).contains("api/user/identities")
+ }
+
+ @Test
+ fun `getLinkedNetworks returns empty when nothing is linked`() = runTest(dispatcher) {
+ enqueueJson(200, """{ "identities": [] }""")
+ val repo = repository()
+
+ val result = repo.getLinkedNetworks()
+
+ assertThat((result as ApiResult.Success).data).isEmpty()
+ }
+
+ @Test
+ fun `createMessage with targets sends the cross-post fields`() = runTest(dispatcher) {
+ enqueueJson(
+ 201,
+ """{ "message": "Message created successfully",
+ "data": { "id": "x1", "content": "cross-posted" } }""",
+ )
+ val repo = repository()
+
+ val result = repo.createMessage(
+ content = "cross-posted",
+ crossPost = CrossPostSelection(
+ mastodonProviderIds = listOf("m1"),
+ linkedIn = true,
+ twitter = true,
+ ),
+ )
+
+ assertThat(result).isInstanceOf(ApiResult.Success::class.java)
+ val body = server.takeRequest().body.readUtf8()
+ assertThat(body).contains("\"mastodonProviderIds\":[\"m1\"]")
+ assertThat(body).contains("\"crossPostToLinkedIn\":true")
+ assertThat(body).contains("\"crossPostToTwitter\":true")
+ // Unselected networks are omitted entirely (explicitNulls = false).
+ assertThat(body).doesNotContain("crossPostToBluesky")
+ }
+
+ @Test
+ fun `createMessage without targets keeps the original body`() = runTest(dispatcher) {
+ enqueueJson(
+ 201,
+ """{ "message": "Message created successfully",
+ "data": { "id": "plain", "content": "just il" } }""",
+ )
+ val repo = repository()
+
+ repo.createMessage(content = "just il")
+
+ val body = server.takeRequest().body.readUtf8()
+ assertThat(body).contains("\"content\":\"just il\"")
+ // None of the cross-post fields are present on a plain post.
+ assertThat(body).doesNotContain("mastodonProviderIds")
+ assertThat(body).doesNotContain("crossPostToBluesky")
+ assertThat(body).doesNotContain("crossPostToLinkedIn")
+ assertThat(body).doesNotContain("crossPostToTwitter")
+ }
+
+ @Test
+ fun `createMessage parses the crossPosts statuses from the response`() = runTest(dispatcher) {
+ enqueueJson(
+ 201,
+ """{ "message": "Message created successfully",
+ "data": { "id": "x2", "content": "cross-posted" },
+ "crossPosts": [
+ { "provider": "linkedin", "status": "success",
+ "url": "https://linkedin.com/post/1" },
+ { "provider": "mastodon:techhub.social", "status": "pending" },
+ { "provider": "twitter", "status": "failed", "error": "rate limited" }
+ ] }""",
+ )
+ val repo = repository()
+
+ val result = repo.createMessage(
+ content = "cross-posted",
+ crossPost = CrossPostSelection(linkedIn = true, twitter = true),
+ )
+
+ val created = (result as ApiResult.Success).data
+ assertThat(created.crossPosts.map { it.provider })
+ .containsExactly("linkedin", "mastodon:techhub.social", "twitter").inOrder()
+ val linkedIn = created.crossPosts.first { it.provider == "linkedin" }
+ assertThat(linkedIn.isSuccess).isTrue()
+ assertThat(linkedIn.url).isEqualTo("https://linkedin.com/post/1")
+ val twitter = created.crossPosts.first { it.provider == "twitter" }
+ assertThat(twitter.isFailed).isTrue()
+ assertThat(twitter.error).isEqualTo("rate limited")
+ }
+
+ @Test
+ fun `createMessage defaults crossPosts to empty when absent`() = runTest(dispatcher) {
+ enqueueJson(
+ 201,
+ """{ "message": "Message created successfully",
+ "data": { "id": "x3", "content": "plain" }, "crossPosts": null }""",
+ )
+ val repo = repository()
+
+ val result = repo.createMessage(content = "plain")
+
+ assertThat((result as ApiResult.Success).data.crossPosts).isEmpty()
+ }
+
@Test
fun `fetchMetadata attaches a link preview to the cached message`() = runTest(dispatcher) {
enqueueJson(
diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/FakeMessageDao.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/FakeMessageDao.kt
index ca684ea..d395836 100644
--- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/FakeMessageDao.kt
+++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/FakeMessageDao.kt
@@ -46,6 +46,10 @@ class FakeMessageDao : MessageDao {
rows.value = rows.value.toMutableMap().apply { remove(id) }
}
+ override suspend fun deleteByAuthorUsername(username: String) {
+ rows.value = rows.value.filterValues { it.authorUsername != username }
+ }
+
override suspend fun clearFeed() {
rows.value = rows.value.filterValues { it.parentId != null || it.scheduledAt != null }
}
diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDtoMapperTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDtoMapperTest.kt
index f60a9e1..65c8df4 100644
--- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDtoMapperTest.kt
+++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDtoMapperTest.kt
@@ -118,6 +118,43 @@ class MessageDtoMapperTest {
assertThat(message.linkPreview).isNull()
}
+ @Test
+ fun `flags an edited message when updatedAt differs from createdAt`() {
+ val message = MessageDto(
+ id = "m8",
+ content = "edited",
+ createdAt = "2026-07-18T10:00:00Z",
+ updatedAt = "2026-07-18T11:30:00Z",
+ ).toDomain(currentUserId = null)
+
+ assertThat(message.isEdited).isTrue()
+ assertThat(message.editedAt).isEqualTo("2026-07-18T11:30:00Z")
+ }
+
+ @Test
+ fun `does not flag as edited when updatedAt equals createdAt`() {
+ val message = MessageDto(
+ id = "m9",
+ content = "fresh",
+ createdAt = "2026-07-18T10:00:00Z",
+ updatedAt = "2026-07-18T10:00:00Z",
+ ).toDomain(currentUserId = null)
+
+ assertThat(message.isEdited).isFalse()
+ assertThat(message.editedAt).isNull()
+ }
+
+ @Test
+ fun `is not edited when updatedAt is absent`() {
+ val message = MessageDto(
+ id = "m10",
+ content = "no updatedAt",
+ createdAt = "2026-07-18T10:00:00Z",
+ ).toDomain(currentUserId = null)
+
+ assertThat(message.isEdited).isFalse()
+ }
+
@Test
fun `drops a link preview without a url`() {
val message = MessageDto(
diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt
index 44cb89f..c0928fb 100644
--- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt
+++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt
@@ -3,6 +3,10 @@ package com.interlinedlist.android.feature.messages.ui
import com.interlinedlist.android.core.common.result.ApiResult
import com.interlinedlist.android.core.common.result.AppError
import com.interlinedlist.android.feature.messages.data.MessagesRepository
+import com.interlinedlist.android.feature.messages.domain.CreatedMessage
+import com.interlinedlist.android.feature.messages.domain.CrossPostSelection
+import com.interlinedlist.android.feature.messages.domain.CrossPostStatus
+import com.interlinedlist.android.feature.messages.domain.LinkedNetwork
import com.interlinedlist.android.feature.messages.domain.Message
import com.interlinedlist.android.feature.messages.domain.ReportReason
import kotlinx.coroutines.flow.Flow
@@ -24,11 +28,18 @@ class FakeMessagesRepository : MessagesRepository {
var refreshResult: ApiResult = ApiResult.Success(false)
var loadMoreResult: ApiResult = ApiResult.Success(false)
var createResult: ApiResult? = null
+ /** Cross-post statuses returned alongside a successful [createResult]. */
+ var createCrossPosts: List = emptyList()
+ var linkedNetworksResult: ApiResult> = ApiResult.Success(emptyList())
var fetchResult: ApiResult? = null
var refreshRepliesResult: ApiResult = ApiResult.Success(Unit)
var postReplyResult: ApiResult? = null
var setDugResult: ApiResult = ApiResult.Success(Unit)
var deleteResult: ApiResult = ApiResult.Success(Unit)
+ var editResult: ApiResult? = null
+ var blockResult: ApiResult = ApiResult.Success(Unit)
+ var muteResult: ApiResult = ApiResult.Success(Unit)
+ var reportUserResult: ApiResult = ApiResult.Success(Unit)
var searchResult: ApiResult> = ApiResult.Success(emptyList())
var uploadImageResult: ApiResult = ApiResult.Success("https://cdn/image.png")
var uploadVideoResult: ApiResult = ApiResult.Success("https://cdn/video.mp4")
@@ -48,6 +59,10 @@ class FakeMessagesRepository : MessagesRepository {
var cancelledScheduledIds = mutableListOf()
var lastReport: ReportArgs? = null
var metadataFetchedIds = mutableListOf()
+ var lastEdit: Pair? = null
+ var blockedUsernames = mutableListOf()
+ var mutedUsernames = mutableListOf()
+ var lastReportUser: ReportUserArgs? = null
/** Snapshot of the arguments passed to the last [createMessage] call. */
data class CreateArgs(
@@ -55,11 +70,15 @@ class FakeMessagesRepository : MessagesRepository {
val imageUrls: List,
val videoUrls: List,
val scheduledAt: String?,
+ val crossPost: CrossPostSelection = CrossPostSelection.NONE,
)
/** Snapshot of the arguments passed to the last [report] call. */
data class ReportArgs(val messageId: String, val reason: ReportReason, val detail: String?)
+ /** Snapshot of the arguments passed to the last [reportUser] call. */
+ data class ReportUserArgs(val username: String, val reason: ReportReason, val detail: String?)
+
fun emitFeed(messages: List) { feed.value = messages }
fun emitReplies(parentId: String, messages: List) {
replies.value = replies.value + (parentId to messages)
@@ -92,11 +111,18 @@ class FakeMessagesRepository : MessagesRepository {
imageUrls: List,
videoUrls: List,
scheduledAt: String?,
- ): ApiResult {
- lastCreate = CreateArgs(content, imageUrls, videoUrls, scheduledAt)
- return createResult ?: ApiResult.Failure(AppError.Unknown("createResult not set"))
+ crossPost: CrossPostSelection,
+ ): ApiResult {
+ lastCreate = CreateArgs(content, imageUrls, videoUrls, scheduledAt, crossPost)
+ return when (val result = createResult) {
+ is ApiResult.Success -> ApiResult.Success(CreatedMessage(result.data, createCrossPosts))
+ is ApiResult.Failure -> result
+ null -> ApiResult.Failure(AppError.Unknown("createResult not set"))
+ }
}
+ override suspend fun getLinkedNetworks(): ApiResult> = linkedNetworksResult
+
override suspend fun uploadImage(bytes: ByteArray, fileName: String, mimeType: String): ApiResult {
uploadedImages++
return uploadImageResult
@@ -125,6 +151,17 @@ class FakeMessagesRepository : MessagesRepository {
return deleteResult
}
+ override suspend fun editMessage(messageId: String, content: String): ApiResult {
+ lastEdit = messageId to content
+ val result = editResult ?: ApiResult.Failure(AppError.Unknown("editResult not set"))
+ if (result is ApiResult.Success) {
+ // Reflect the edit into the observable feed/message so the UI re-emits.
+ feed.value = feed.value.map { if (it.id == messageId) result.data else it }
+ single.value = single.value + (messageId to result.data)
+ }
+ return result
+ }
+
override suspend fun refreshScheduled(): ApiResult {
refreshScheduledCount++
return refreshScheduledResult
@@ -140,6 +177,30 @@ class FakeMessagesRepository : MessagesRepository {
return reportResult
}
+ override suspend fun blockUser(username: String): ApiResult {
+ blockedUsernames += username
+ val result = blockResult
+ if (result is ApiResult.Success) {
+ // Mirror the repository's hide-on-block behaviour for ViewModel tests.
+ feed.value = feed.value.filterNot { it.authorUsername == username }
+ }
+ return result
+ }
+
+ override suspend fun muteUser(username: String): ApiResult {
+ mutedUsernames += username
+ val result = muteResult
+ if (result is ApiResult.Success) {
+ feed.value = feed.value.filterNot { it.authorUsername == username }
+ }
+ return result
+ }
+
+ override suspend fun reportUser(username: String, reason: ReportReason, detail: String?): ApiResult {
+ lastReportUser = ReportUserArgs(username, reason, detail)
+ return reportUserResult
+ }
+
override suspend fun fetchMetadata(messageId: String): ApiResult {
metadataFetchedIds += messageId
return metadataResult ?: ApiResult.Failure(AppError.Unknown("metadataResult not set"))
@@ -148,6 +209,17 @@ class FakeMessagesRepository : MessagesRepository {
override suspend fun search(query: String): ApiResult> = searchResult
}
+/** Builds a sample [LinkedNetwork] for tests. */
+fun sampleNetwork(
+ id: String,
+ provider: String,
+ providerUsername: String = "handle",
+) = LinkedNetwork(
+ id = id,
+ provider = provider,
+ providerUsername = providerUsername,
+)
+
/** Builds a sample [Message] for tests. */
fun sampleMessage(
id: String = "1",
@@ -160,11 +232,13 @@ fun sampleMessage(
imageUrls: List = emptyList(),
videoUrls: List = emptyList(),
scheduledAt: String? = null,
+ authorUsername: String = "adron",
+ editedAt: String? = null,
) = Message(
id = id,
content = content,
authorId = "u1",
- authorUsername = "adron",
+ authorUsername = authorUsername,
authorDisplayName = "Adron",
authorAvatarUrl = null,
createdAt = null,
@@ -176,4 +250,5 @@ fun sampleMessage(
imageUrls = imageUrls,
videoUrls = videoUrls,
scheduledAt = scheduledAt,
+ editedAt = editedAt,
)
diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModelTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModelTest.kt
index 45450d6..815647a 100644
--- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModelTest.kt
+++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModelTest.kt
@@ -6,6 +6,7 @@ import com.interlinedlist.android.core.common.result.ApiResult
import com.interlinedlist.android.core.common.result.AppError
import com.interlinedlist.android.feature.messages.domain.ReportReason
import com.interlinedlist.android.feature.messages.ui.FakeMessagesRepository
+import com.interlinedlist.android.feature.messages.ui.feed.ModerationAction
import com.interlinedlist.android.feature.messages.ui.sampleMessage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -148,6 +149,92 @@ class MessageDetailViewModelTest {
assertThat(vm.uiState.value.reportTarget).isNull()
}
+ @Test
+ fun `edit seeds and saves the current message content`() = runTest(dispatcher) {
+ val repo = FakeMessagesRepository().apply {
+ fetchResult = ApiResult.Success(sampleMessage(id = "m1", content = "original", mine = true))
+ editResult = ApiResult.Success(
+ sampleMessage(id = "m1", content = "edited", mine = true, editedAt = "2026-07-31T12:00:00Z"),
+ )
+ }
+ repo.emitMessage(sampleMessage(id = "m1", content = "original", mine = true))
+ val vm = MessageDetailViewModel(repo, handle("m1"))
+ backgroundScope.launch { vm.uiState.collect {} }
+ advanceUntilIdle()
+
+ vm.openEdit(sampleMessage(id = "m1", content = "original", mine = true))
+ advanceUntilIdle()
+ assertThat(vm.uiState.value.editText).isEqualTo("original")
+
+ vm.onEditTextChange("edited")
+ vm.saveEdit()
+ advanceUntilIdle()
+
+ assertThat(repo.lastEdit).isEqualTo("m1" to "edited")
+ assertThat(vm.uiState.value.editTarget).isNull()
+ assertThat(vm.uiState.value.message?.content).isEqualTo("edited")
+ assertThat(vm.uiState.value.message?.isEdited).isTrue()
+ }
+
+ @Test
+ fun `edit failure keeps the sheet open and shows an error`() = runTest(dispatcher) {
+ val repo = FakeMessagesRepository().apply {
+ fetchResult = ApiResult.Success(sampleMessage(id = "m1", mine = true))
+ editResult = ApiResult.Failure(AppError.Server("nope"))
+ }
+ val vm = MessageDetailViewModel(repo, handle("m1"))
+ backgroundScope.launch { vm.uiState.collect {} }
+ advanceUntilIdle()
+
+ vm.openEdit(sampleMessage(id = "m1", content = "original", mine = true))
+ vm.onEditTextChange("changed")
+ vm.saveEdit()
+ advanceUntilIdle()
+
+ assertThat(vm.uiState.value.editTarget?.id).isEqualTo("m1")
+ assertThat(vm.uiState.value.errorMessage).isNotEmpty()
+ }
+
+ @Test
+ fun `block on a reply delegates to the repository`() = runTest(dispatcher) {
+ val repo = FakeMessagesRepository().apply {
+ fetchResult = ApiResult.Success(sampleMessage(id = "m1"))
+ }
+ val vm = MessageDetailViewModel(repo, handle("m1"))
+ backgroundScope.launch { vm.uiState.collect {} }
+ advanceUntilIdle()
+
+ val reply = sampleMessage(id = "r1", parentId = "m1", authorUsername = "amy")
+ vm.openModeration(reply, ModerationAction.BLOCK)
+ advanceUntilIdle()
+ assertThat(vm.uiState.value.moderationTarget?.username).isEqualTo("amy")
+
+ vm.confirmModeration()
+ advanceUntilIdle()
+
+ assertThat(repo.blockedUsernames).containsExactly("amy")
+ assertThat(vm.uiState.value.moderationTarget).isNull()
+ }
+
+ @Test
+ fun `report user submits the reason and detail`() = runTest(dispatcher) {
+ val repo = FakeMessagesRepository().apply {
+ fetchResult = ApiResult.Success(sampleMessage(id = "m1"))
+ }
+ val vm = MessageDetailViewModel(repo, handle("m1"))
+ backgroundScope.launch { vm.uiState.collect {} }
+ advanceUntilIdle()
+
+ vm.openModeration(sampleMessage(id = "m1", authorUsername = "amy"), ModerationAction.REPORT)
+ vm.confirmModeration(ReportReason.SPAM, "spammer")
+ advanceUntilIdle()
+
+ val report = repo.lastReportUser!!
+ assertThat(report.username).isEqualTo("amy")
+ assertThat(report.reason).isEqualTo(ReportReason.SPAM)
+ assertThat(report.detail).isEqualTo("spammer")
+ }
+
@Test
fun `fetchMetadata delegates for the current message`() = runTest(dispatcher) {
val repo = FakeMessagesRepository().apply {
diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt
index c3e1616..b354d92 100644
--- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt
+++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt
@@ -4,9 +4,11 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.interlinedlist.android.core.common.result.ApiResult
import com.interlinedlist.android.core.common.result.AppError
+import com.interlinedlist.android.feature.messages.domain.CrossPostStatus
import com.interlinedlist.android.feature.messages.domain.ReportReason
import com.interlinedlist.android.feature.messages.ui.FakeMessagesRepository
import com.interlinedlist.android.feature.messages.ui.sampleMessage
+import com.interlinedlist.android.feature.messages.ui.sampleNetwork
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
@@ -286,6 +288,275 @@ class MessagesFeedViewModelTest {
assertThat(vm.uiState.value.reportTarget).isNull()
}
+ @Test
+ fun `edit seeds the sheet and saves the new content marking it edited`() = runTest(dispatcher) {
+ val repo = FakeMessagesRepository().apply {
+ editResult = ApiResult.Success(
+ sampleMessage(id = "own", content = "updated body", mine = true, editedAt = "2026-07-31T12:00:00Z"),
+ )
+ }
+ repo.emitFeed(listOf(sampleMessage(id = "own", content = "original body", mine = true)))
+ val vm = MessagesFeedViewModel(repo)
+ backgroundScope.launch { vm.uiState.collect {} }
+ advanceUntilIdle()
+
+ vm.openEdit(sampleMessage(id = "own", content = "original body", mine = true))
+ advanceUntilIdle()
+ // Editor is seeded with the current content.
+ assertThat(vm.uiState.value.editTarget?.id).isEqualTo("own")
+ assertThat(vm.uiState.value.editText).isEqualTo("original body")
+
+ vm.onEditTextChange("updated body")
+ vm.saveEdit()
+ advanceUntilIdle()
+
+ assertThat(repo.lastEdit).isEqualTo("own" to "updated body")
+ // Sheet closed and the feed reflects the edited, marked message.
+ assertThat(vm.uiState.value.editTarget).isNull()
+ val edited = vm.uiState.value.messages.first { it.id == "own" }
+ assertThat(edited.content).isEqualTo("updated body")
+ assertThat(edited.isEdited).isTrue()
+ }
+
+ @Test
+ fun `edit failure keeps the sheet open and surfaces an error`() = runTest(dispatcher) {
+ val repo = FakeMessagesRepository().apply {
+ editResult = ApiResult.Failure(AppError.Server("nope"))
+ }
+ val vm = MessagesFeedViewModel(repo)
+ backgroundScope.launch { vm.uiState.collect {} }
+ advanceUntilIdle()
+
+ vm.openEdit(sampleMessage(id = "own", content = "original", mine = true))
+ vm.onEditTextChange("changed")
+ vm.saveEdit()
+ advanceUntilIdle()
+
+ val state = vm.uiState.value
+ assertThat(state.editTarget?.id).isEqualTo("own")
+ assertThat(state.isSavingEdit).isFalse()
+ assertThat(state.errorMessage).isNotEmpty()
+ }
+
+ @Test
+ fun `block hides the author's messages from the feed`() = runTest(dispatcher) {
+ val repo = FakeMessagesRepository()
+ repo.emitFeed(
+ listOf(
+ sampleMessage(id = "1", authorUsername = "amy"),
+ sampleMessage(id = "2", authorUsername = "bob"),
+ ),
+ )
+ val vm = MessagesFeedViewModel(repo)
+ backgroundScope.launch { vm.uiState.collect {} }
+ advanceUntilIdle()
+
+ vm.openModeration(sampleMessage(id = "1", authorUsername = "amy"), ModerationAction.BLOCK)
+ advanceUntilIdle()
+ assertThat(vm.uiState.value.moderationTarget?.username).isEqualTo("amy")
+
+ vm.confirmModeration()
+ advanceUntilIdle()
+
+ assertThat(repo.blockedUsernames).containsExactly("amy")
+ assertThat(vm.uiState.value.moderationTarget).isNull()
+ assertThat(vm.uiState.value.messages.map { it.id }).containsExactly("2")
+ }
+
+ @Test
+ fun `block failure surfaces an error and keeps the feed`() = runTest(dispatcher) {
+ val repo = FakeMessagesRepository().apply {
+ blockResult = ApiResult.Failure(AppError.Server("boom"))
+ }
+ repo.emitFeed(listOf(sampleMessage(id = "1", authorUsername = "amy")))
+ val vm = MessagesFeedViewModel(repo)
+ backgroundScope.launch { vm.uiState.collect {} }
+ advanceUntilIdle()
+
+ vm.openModeration(sampleMessage(id = "1", authorUsername = "amy"), ModerationAction.BLOCK)
+ vm.confirmModeration()
+ advanceUntilIdle()
+
+ assertThat(vm.uiState.value.errorMessage).isNotEmpty()
+ assertThat(vm.uiState.value.messages.map { it.id }).containsExactly("1")
+ }
+
+ @Test
+ fun `mute delegates to the repository`() = runTest(dispatcher) {
+ val repo = FakeMessagesRepository()
+ repo.emitFeed(listOf(sampleMessage(id = "1", authorUsername = "amy")))
+ val vm = MessagesFeedViewModel(repo)
+ backgroundScope.launch { vm.uiState.collect {} }
+ advanceUntilIdle()
+
+ vm.openModeration(sampleMessage(id = "1", authorUsername = "amy"), ModerationAction.MUTE)
+ vm.confirmModeration()
+ advanceUntilIdle()
+
+ assertThat(repo.mutedUsernames).containsExactly("amy")
+ assertThat(vm.uiState.value.messages).isEmpty()
+ }
+
+ @Test
+ fun `report user submits the chosen reason and detail`() = runTest(dispatcher) {
+ val repo = FakeMessagesRepository()
+ val vm = MessagesFeedViewModel(repo)
+ backgroundScope.launch { vm.uiState.collect {} }
+ advanceUntilIdle()
+
+ vm.openModeration(sampleMessage(id = "1", authorUsername = "amy"), ModerationAction.REPORT)
+ vm.confirmModeration(ReportReason.HARASSMENT, "abusive")
+ advanceUntilIdle()
+
+ val report = repo.lastReportUser!!
+ assertThat(report.username).isEqualTo("amy")
+ assertThat(report.reason).isEqualTo(ReportReason.HARASSMENT)
+ assertThat(report.detail).isEqualTo("abusive")
+ assertThat(vm.uiState.value.moderationTarget).isNull()
+ }
+
+ // --- cross-posting -----------------------------------------------------
+
+ @Test
+ fun `linked networks are loaded on init`() = runTest(dispatcher) {
+ val repo = FakeMessagesRepository().apply {
+ linkedNetworksResult = ApiResult.Success(
+ listOf(
+ sampleNetwork(id = "m1", provider = "mastodon:techhub.social"),
+ sampleNetwork(id = "l1", provider = "linkedin"),
+ ),
+ )
+ }
+ val vm = MessagesFeedViewModel(repo)
+
+ vm.uiState.test {
+ advanceUntilIdle()
+ val state = expectMostRecentItem()
+ assertThat(state.linkedNetworks.map { it.id }).containsExactly("m1", "l1").inOrder()
+ assertThat(state.hasNoLinkedNetworks).isFalse()
+ }
+ }
+
+ @Test
+ fun `empty linked networks yields the no-networks state`() = runTest(dispatcher) {
+ val repo = FakeMessagesRepository().apply {
+ linkedNetworksResult = ApiResult.Success(emptyList())
+ }
+ val vm = MessagesFeedViewModel(repo)
+
+ vm.uiState.test {
+ advanceUntilIdle()
+ val state = expectMostRecentItem()
+ assertThat(state.linkedNetworks).isEmpty()
+ assertThat(state.hasNoLinkedNetworks).isTrue()
+ }
+ }
+
+ @Test
+ fun `toggling a network selects then deselects it`() = runTest(dispatcher) {
+ val repo = FakeMessagesRepository().apply {
+ linkedNetworksResult = ApiResult.Success(
+ listOf(sampleNetwork(id = "l1", provider = "linkedin")),
+ )
+ }
+ val vm = MessagesFeedViewModel(repo)
+ backgroundScope.launch { vm.uiState.collect {} }
+ advanceUntilIdle()
+
+ vm.onToggleNetwork("l1")
+ advanceUntilIdle()
+ assertThat(vm.uiState.value.selectedNetworkIds).containsExactly("l1")
+
+ vm.onToggleNetwork("l1")
+ advanceUntilIdle()
+ assertThat(vm.uiState.value.selectedNetworkIds).isEmpty()
+ }
+
+ @Test
+ fun `post includes the selected networks as cross-post targets`() = runTest(dispatcher) {
+ val repo = FakeMessagesRepository().apply {
+ linkedNetworksResult = ApiResult.Success(
+ listOf(
+ sampleNetwork(id = "m1", provider = "mastodon:techhub.social"),
+ sampleNetwork(id = "l1", provider = "linkedin"),
+ sampleNetwork(id = "b1", provider = "bluesky"),
+ ),
+ )
+ createResult = ApiResult.Success(sampleMessage(id = "new"))
+ }
+ val vm = MessagesFeedViewModel(repo)
+ backgroundScope.launch { vm.uiState.collect {} }
+ advanceUntilIdle()
+
+ vm.openCompose()
+ vm.onComposeTextChange("hello networks")
+ vm.onToggleNetwork("m1")
+ vm.onToggleNetwork("l1")
+ advanceUntilIdle()
+ vm.post()
+ advanceUntilIdle()
+
+ val crossPost = repo.lastCreate!!.crossPost
+ assertThat(crossPost.mastodonProviderIds).containsExactly("m1")
+ assertThat(crossPost.linkedIn).isTrue()
+ // Bluesky was never toggled.
+ assertThat(crossPost.bluesky).isFalse()
+ assertThat(crossPost.twitter).isFalse()
+ // Selection is cleared after a successful post.
+ assertThat(vm.uiState.value.selectedNetworkIds).isEmpty()
+ }
+
+ @Test
+ fun `post with no targets sends an empty selection`() = runTest(dispatcher) {
+ val repo = FakeMessagesRepository().apply {
+ linkedNetworksResult = ApiResult.Success(
+ listOf(sampleNetwork(id = "l1", provider = "linkedin")),
+ )
+ createResult = ApiResult.Success(sampleMessage(id = "new"))
+ }
+ val vm = MessagesFeedViewModel(repo)
+ backgroundScope.launch { vm.uiState.collect {} }
+ advanceUntilIdle()
+
+ vm.openCompose()
+ vm.onComposeTextChange("just il")
+ vm.post()
+ advanceUntilIdle()
+
+ assertThat(repo.lastCreate!!.crossPost.hasTargets).isFalse()
+ }
+
+ @Test
+ fun `a successful cross-post surfaces the per-network statuses`() = runTest(dispatcher) {
+ val repo = FakeMessagesRepository().apply {
+ linkedNetworksResult = ApiResult.Success(
+ listOf(sampleNetwork(id = "l1", provider = "linkedin")),
+ )
+ createResult = ApiResult.Success(sampleMessage(id = "new"))
+ createCrossPosts = listOf(
+ CrossPostStatus(provider = "linkedin", status = "success", url = "https://li/1"),
+ )
+ }
+ val vm = MessagesFeedViewModel(repo)
+ backgroundScope.launch { vm.uiState.collect {} }
+ advanceUntilIdle()
+
+ vm.openCompose()
+ vm.onComposeTextChange("cross-posted")
+ vm.onToggleNetwork("l1")
+ advanceUntilIdle()
+ vm.post()
+ advanceUntilIdle()
+
+ val statuses = vm.uiState.value.crossPostStatuses
+ assertThat(statuses.map { it.provider }).containsExactly("linkedin")
+ assertThat(statuses.first().isSuccess).isTrue()
+
+ vm.dismissCrossPostStatuses()
+ advanceUntilIdle()
+ assertThat(vm.uiState.value.crossPostStatuses).isEmpty()
+ }
+
@Test
fun `fetchMetadata delegates to the repository`() = runTest(dispatcher) {
val repo = FakeMessagesRepository().apply {
diff --git a/feature/notifications/build.gradle.kts b/feature/notifications/build.gradle.kts
index 827ee9f..f0f0b30 100644
--- a/feature/notifications/build.gradle.kts
+++ b/feature/notifications/build.gradle.kts
@@ -32,6 +32,10 @@ dependencies {
implementation(project(":core:network"))
implementation(project(":core:datastore"))
+ // core-ktx provides NotificationManagerCompat / NotificationCompat and
+ // getSystemService used by the system-notification poll (push tray).
+ implementation(libs.androidx.core.ktx)
+
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.material3)
@@ -53,6 +57,11 @@ dependencies {
ksp(libs.hilt.compiler)
implementation(libs.androidx.hilt.navigation.compose)
+ // Background notification polling via WorkManager, with Hilt-injected workers.
+ implementation(libs.androidx.work.runtime.ktx)
+ implementation(libs.androidx.hilt.work)
+ ksp(libs.androidx.hilt.compiler)
+
implementation(libs.coil.compose)
implementation(libs.retrofit.core)
diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationsRepository.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationsRepository.kt
index 72c20e2..c8e8d7d 100644
--- a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationsRepository.kt
+++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationsRepository.kt
@@ -30,6 +30,13 @@ class DefaultNotificationsRepository @Inject constructor(
override fun observeUnreadCount(): Flow = notificationDao.observeUnreadCount()
+ override suspend fun fetchLatest(): ApiResult> = withContext(dispatchers.io) {
+ when (val result = safeCall { api.getNotifications(limit = PaginationDto.DEFAULT_LIMIT, offset = 0) }) {
+ is ApiResult.Success -> ApiResult.Success(result.data.items.map { it.toDomain() })
+ is ApiResult.Failure -> result
+ }
+ }
+
override suspend fun refresh(): ApiResult = withContext(dispatchers.io) {
when (val result = safeCall { api.getNotifications(limit = PaginationDto.DEFAULT_LIMIT, offset = 0) }) {
is ApiResult.Success -> {
diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/NotificationsRepository.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/NotificationsRepository.kt
index 14d128d..0566ada 100644
--- a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/NotificationsRepository.kt
+++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/NotificationsRepository.kt
@@ -21,6 +21,13 @@ interface NotificationsRepository {
/** Live count of unread notifications (drives the badge/header styling). */
fun observeUnreadCount(): Flow
+ /**
+ * Fetches the first page of notifications from the API as domain models WITHOUT
+ * touching the Room cache. Used by the background poll worker, which must not
+ * disturb the offline-first feed the UI observes.
+ */
+ suspend fun fetchLatest(): ApiResult>
+
/**
* Refreshes the first page from the API and replaces the cached list.
* Returns whether more pages are available.
diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponse.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponse.kt
index 87fe0fd..3df79ce 100644
--- a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponse.kt
+++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponse.kt
@@ -1,5 +1,6 @@
package com.interlinedlist.android.feature.notifications.data.remote.dto
+import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
@@ -12,6 +13,8 @@ import kotlinx.serialization.Serializable
*/
@Serializable
data class NotificationsResponse(
+ /** Primary key the live API uses: `{ unreadCount, items: [...] }`. */
+ @SerialName("items") private val itemsKey: List = emptyList(),
val data: List = emptyList(),
/** Alternate key some payloads use for the list. */
val notifications: List = emptyList(),
@@ -19,8 +22,8 @@ data class NotificationsResponse(
/** Server-provided unread count, when present; otherwise derived from [items]. */
val unreadCount: Int? = null,
) {
- /** The notification list, whichever key the server populated. */
- val items: List get() = data.ifEmpty { notifications }
+ /** The notification list, whichever key the server populated (`items`, `data`, or `notifications`). */
+ val items: List get() = itemsKey.ifEmpty { data.ifEmpty { notifications } }
}
/** Pagination cursor returned alongside a list of notifications. */
diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/di/NotificationsModule.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/di/NotificationsModule.kt
index ae5c8b1..4a9311f 100644
--- a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/di/NotificationsModule.kt
+++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/di/NotificationsModule.kt
@@ -7,6 +7,10 @@ import com.interlinedlist.android.feature.notifications.data.NotificationsReposi
import com.interlinedlist.android.feature.notifications.data.local.NotificationDao
import com.interlinedlist.android.feature.notifications.data.local.NotificationsDatabase
import com.interlinedlist.android.feature.notifications.data.remote.NotificationsApi
+import com.interlinedlist.android.feature.notifications.push.LastSeenNotificationStore
+import com.interlinedlist.android.feature.notifications.push.SharedPrefsLastSeenNotificationStore
+import com.interlinedlist.android.feature.notifications.push.SystemNotificationPoster
+import com.interlinedlist.android.feature.notifications.push.SystemNotificationRaiser
import dagger.Binds
import dagger.Module
import dagger.Provides
@@ -26,6 +30,13 @@ abstract class NotificationsRepositoryModule {
abstract fun bindNotificationsRepository(
impl: DefaultNotificationsRepository,
): NotificationsRepository
+
+ /** Persists the background-poll last-seen marker (see NotificationsPollWorker). */
+ @Binds
+ @Singleton
+ abstract fun bindLastSeenNotificationStore(
+ impl: SharedPrefsLastSeenNotificationStore,
+ ): LastSeenNotificationStore
}
/**
@@ -56,4 +67,11 @@ object NotificationsDataModule {
@Provides
fun provideNotificationDao(db: NotificationsDatabase): NotificationDao = db.notificationDao()
+
+ /** The system-tray poster used by the background notification poll worker. */
+ @Provides
+ @Singleton
+ fun provideSystemNotificationRaiser(
+ @ApplicationContext context: Context,
+ ): SystemNotificationRaiser = SystemNotificationPoster(context)
}
diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/LastSeenNotificationStore.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/LastSeenNotificationStore.kt
new file mode 100644
index 0000000..01bf1c9
--- /dev/null
+++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/LastSeenNotificationStore.kt
@@ -0,0 +1,53 @@
+package com.interlinedlist.android.feature.notifications.push
+
+import android.content.Context
+import android.content.SharedPreferences
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Persists the "last-seen" marker for the background notification poll so the
+ * worker only raises a system notification for items that arrived since the
+ * previous run — never re-notifying on every poll.
+ *
+ * The marker is the newest notification id the poll has already surfaced. It is a
+ * tiny scalar with no secrets, so it lives in plain SharedPreferences (mirroring
+ * [com.interlinedlist.android.core.datastore.ThemeSettingsStore]). Modelled behind
+ * an interface so the worker's selection logic can be unit-tested with an in-memory
+ * fake, with no Android dependency.
+ */
+interface LastSeenNotificationStore {
+
+ /** The id of the newest notification already surfaced, or null on first run. */
+ fun lastSeenId(): String?
+
+ /** Records [id] as the newest notification already surfaced. */
+ fun setLastSeenId(id: String)
+
+ /** Whether any marker has been recorded yet (false = first ever poll). */
+ fun hasSeenAny(): Boolean
+}
+
+/** SharedPreferences-backed [LastSeenNotificationStore]. */
+@Singleton
+class SharedPrefsLastSeenNotificationStore @Inject constructor(
+ @ApplicationContext context: Context,
+) : LastSeenNotificationStore {
+
+ private val prefs: SharedPreferences =
+ context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)
+
+ override fun lastSeenId(): String? = prefs.getString(KEY_LAST_SEEN_ID, null)
+
+ override fun setLastSeenId(id: String) {
+ prefs.edit().putString(KEY_LAST_SEEN_ID, id).apply()
+ }
+
+ override fun hasSeenAny(): Boolean = prefs.contains(KEY_LAST_SEEN_ID)
+
+ companion object {
+ private const val PREFS_FILE = "il_notifications_poll.prefs"
+ private const val KEY_LAST_SEEN_ID = "last_seen_notification_id"
+ }
+}
diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NewNotificationsSelector.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NewNotificationsSelector.kt
new file mode 100644
index 0000000..aee2975
--- /dev/null
+++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NewNotificationsSelector.kt
@@ -0,0 +1,68 @@
+package com.interlinedlist.android.feature.notifications.push
+
+import com.interlinedlist.android.feature.notifications.domain.Notification
+
+/**
+ * Pure logic that decides which notifications are NEW relative to the last-seen
+ * marker, and what the new marker should be. No Android or IO dependencies, so it
+ * is fully unit-testable.
+ *
+ * Input is the API's newest-first page (index 0 is the most recent). Behaviour:
+ * - The newest id in the page becomes the [SelectionResult.newLastSeenId] to persist.
+ * - When a [lastSeenId] is present, everything strictly newer than it (i.e. every
+ * item preceding the matching id in the newest-first list) is NEW.
+ * - When [lastSeenId] is not found in the page (it fell off the end, or the list was
+ * cleared server-side), we conservatively treat the whole page as NEW rather than
+ * guessing — the cap in the poster keeps this from spamming.
+ * - On the very FIRST poll ([hasSeenAny] == false) we surface nothing and simply
+ * record the newest id, so logging in / installing never dumps the entire backlog
+ * into the tray.
+ */
+object NewNotificationsSelector {
+
+ /**
+ * @param notifications the newest-first page from `GET /api/notifications`.
+ * @param lastSeenId the previously-recorded newest id (null on first run).
+ * @param hasSeenAny whether any marker has ever been recorded.
+ */
+ fun select(
+ notifications: List,
+ lastSeenId: String?,
+ hasSeenAny: Boolean,
+ ): SelectionResult {
+ if (notifications.isEmpty()) {
+ // Nothing to surface; keep the existing marker untouched.
+ return SelectionResult(newNotifications = emptyList(), newLastSeenId = lastSeenId)
+ }
+
+ val newestId = notifications.first().id
+
+ // First ever poll: adopt the newest id as a baseline, surface nothing.
+ if (!hasSeenAny || lastSeenId == null) {
+ return SelectionResult(newNotifications = emptyList(), newLastSeenId = newestId)
+ }
+
+ // Already up to date.
+ if (newestId == lastSeenId) {
+ return SelectionResult(newNotifications = emptyList(), newLastSeenId = newestId)
+ }
+
+ val markerIndex = notifications.indexOfFirst { it.id == lastSeenId }
+ val newItems = if (markerIndex >= 0) {
+ notifications.subList(0, markerIndex).toList()
+ } else {
+ // Marker not in this page — treat the whole page as new (bounded by the cap).
+ notifications.toList()
+ }
+
+ return SelectionResult(newNotifications = newItems, newLastSeenId = newestId)
+ }
+}
+
+/** Outcome of [NewNotificationsSelector.select]. */
+data class SelectionResult(
+ /** Notifications that arrived since the last poll, newest-first. */
+ val newNotifications: List,
+ /** The marker to persist as the new last-seen id (null only when there is nothing to record). */
+ val newLastSeenId: String?,
+)
diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationCategory.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationCategory.kt
new file mode 100644
index 0000000..d81ef51
--- /dev/null
+++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationCategory.kt
@@ -0,0 +1,73 @@
+package com.interlinedlist.android.feature.notifications.push
+
+import com.interlinedlist.android.feature.notifications.domain.NotificationType
+
+/**
+ * A system-notification channel category. Each maps a coarse [NotificationType] to a
+ * user-facing Android notification channel (registered on O+) and to the notification
+ * -preference event keys that gate its `push` delivery.
+ *
+ * The channel ids are stable strings persisted by the OS, so they must not change once
+ * shipped. [preferenceKeys] lists the candidate event keys the backend uses for this
+ * category (`GET /api/user/notification-preferences`); the first one present in the
+ * fetched preferences wins when deciding whether push is enabled.
+ */
+enum class NotificationCategory(
+ val channelId: String,
+ val channelName: String,
+ val channelDescription: String,
+ val preferenceKeys: List,
+) {
+ MESSAGES(
+ channelId = "il_messages",
+ channelName = "Messages",
+ channelDescription = "New messages and direct messages",
+ preferenceKeys = listOf("message", "messages", "dm", "direct_message"),
+ ),
+ FOLLOWS(
+ channelId = "il_follows",
+ channelName = "Follows",
+ channelDescription = "New followers and follow requests",
+ preferenceKeys = listOf("follow", "follower", "new_follower", "follow_request"),
+ ),
+ DIGS(
+ channelId = "il_digs",
+ channelName = "Digs",
+ channelDescription = "When someone digs your content",
+ preferenceKeys = listOf("dig", "like", "favorite"),
+ ),
+ MENTIONS(
+ channelId = "il_mentions",
+ channelName = "Mentions",
+ channelDescription = "When someone mentions you",
+ preferenceKeys = listOf("mention", "tag"),
+ ),
+ REPLIES(
+ channelId = "il_replies",
+ channelName = "Replies",
+ channelDescription = "Replies and comments on your content",
+ preferenceKeys = listOf("reply", "comment"),
+ ),
+ OTHER(
+ channelId = "il_other",
+ channelName = "Other",
+ channelDescription = "Shared lists, system and account notifications",
+ preferenceKeys = listOf("list", "list_share", "system", "account"),
+ );
+
+ companion object {
+
+ /** Maps a coarse [NotificationType] to the category whose channel it belongs to. */
+ fun forType(type: NotificationType): NotificationCategory = when (type) {
+ NotificationType.MESSAGE -> MESSAGES
+ NotificationType.FOLLOW -> FOLLOWS
+ NotificationType.LIKE -> DIGS
+ NotificationType.MENTION -> MENTIONS
+ NotificationType.REPLY -> REPLIES
+ NotificationType.LIST_SHARE,
+ NotificationType.SYSTEM,
+ NotificationType.OTHER,
+ -> OTHER
+ }
+ }
+}
diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationDeepLink.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationDeepLink.kt
new file mode 100644
index 0000000..bca5054
--- /dev/null
+++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationDeepLink.kt
@@ -0,0 +1,65 @@
+package com.interlinedlist.android.feature.notifications.push
+
+import com.interlinedlist.android.feature.notifications.domain.Notification
+import com.interlinedlist.android.feature.notifications.domain.NotificationTargetKind
+
+/**
+ * The intent extras carried by a tapped system notification, and the pure logic that
+ * turns a [Notification]'s target into a destination the app can route to.
+ *
+ * The worker attaches these extras to the [android.app.PendingIntent] that opens
+ * [MainActivity]; the app reads them and navigates. Keeping the keys and the
+ * type→destination mapping here means the module owns the contract and it stays
+ * unit-testable without any Android UI.
+ */
+object NotificationDeepLink {
+
+ /** Extra flagging that the launch originated from a notification tap. */
+ const val EXTRA_FROM_NOTIFICATION = "il.extra.from_notification"
+
+ /** Extra naming the destination the app should route to (see [Destination]). */
+ const val EXTRA_DESTINATION = "il.extra.notification_destination"
+
+ /** Extra carrying the target entity id, when the destination needs one. */
+ const val EXTRA_TARGET_ID = "il.extra.notification_target_id"
+
+ /**
+ * Where a tapped notification should land. The app maps these to concrete nav
+ * routes; anything without a resolvable target falls back to [NOTIFICATIONS].
+ */
+ enum class Destination {
+ /** Open a specific message thread ([EXTRA_TARGET_ID] = message id). */
+ MESSAGE,
+
+ /** Open a user's profile ([EXTRA_TARGET_ID] = username/id). */
+ USER,
+
+ /** Open a specific list ([EXTRA_TARGET_ID] = list id). */
+ LIST,
+
+ /** Open the in-app notifications feed (the safe fallback). */
+ NOTIFICATIONS,
+ }
+
+ /**
+ * Resolves the destination for [notification] from its target. Falls back to
+ * [Destination.NOTIFICATIONS] when there is no usable, specific target.
+ */
+ fun destinationFor(notification: Notification): Destination {
+ val target = notification.target ?: return Destination.NOTIFICATIONS
+ if (target.id.isBlank()) return Destination.NOTIFICATIONS
+ return when (target.kind) {
+ NotificationTargetKind.MESSAGE -> Destination.MESSAGE
+ NotificationTargetKind.USER -> Destination.USER
+ NotificationTargetKind.LIST -> Destination.LIST
+ NotificationTargetKind.OTHER -> Destination.NOTIFICATIONS
+ }
+ }
+
+ /** The target id to carry for [notification], or null when routing to the feed. */
+ fun targetIdFor(notification: Notification): String? =
+ when (destinationFor(notification)) {
+ Destination.NOTIFICATIONS -> null
+ else -> notification.target?.id?.takeIf { it.isNotBlank() }
+ }
+}
diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollProcessor.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollProcessor.kt
new file mode 100644
index 0000000..99ff4cb
--- /dev/null
+++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollProcessor.kt
@@ -0,0 +1,50 @@
+package com.interlinedlist.android.feature.notifications.push
+
+import com.interlinedlist.android.feature.notifications.domain.Notification
+import com.interlinedlist.android.feature.notifications.domain.NotificationPreference
+
+/**
+ * Pure orchestration of one poll pass, with no Android/IO dependencies so it is fully
+ * unit-testable. Given the freshly-fetched notifications, the current preferences, and
+ * the persisted last-seen state, it decides:
+ * - which notifications must be raised in the tray (NEW since last-seen AND push-enabled), and
+ * - the marker to persist next.
+ *
+ * The worker feeds it inputs it gathered over the network / store and applies its
+ * [Outcome] (post + persist). This keeps the "what to do" logic separate from the
+ * "how to do it" side-effects (SRP).
+ */
+object NotificationPollProcessor {
+
+ /**
+ * @param fetched newest-first page from `GET /api/notifications`.
+ * @param preferences current per-event preferences (empty if the fetch failed).
+ * @param lastSeenId the previously-recorded newest id (null on first run).
+ * @param hasSeenAny whether any marker has ever been recorded.
+ */
+ fun process(
+ fetched: List,
+ preferences: List,
+ lastSeenId: String?,
+ hasSeenAny: Boolean,
+ ): Outcome {
+ val selection = NewNotificationsSelector.select(
+ notifications = fetched,
+ lastSeenId = lastSeenId,
+ hasSeenAny = hasSeenAny,
+ )
+ val toPost = NotificationPushFilter(preferences).filter(selection.newNotifications)
+ return Outcome(
+ toPost = toPost,
+ newLastSeenId = selection.newLastSeenId,
+ )
+ }
+
+ /** The actions the worker should apply after a poll pass. */
+ data class Outcome(
+ /** Notifications to raise in the system tray (newest-first). */
+ val toPost: List,
+ /** The marker to persist, or null when there is nothing to record. */
+ val newLastSeenId: String?,
+ )
+}
diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollRunner.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollRunner.kt
new file mode 100644
index 0000000..654b2c1
--- /dev/null
+++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollRunner.kt
@@ -0,0 +1,61 @@
+package com.interlinedlist.android.feature.notifications.push
+
+import com.interlinedlist.android.core.common.result.ApiResult
+import com.interlinedlist.android.feature.notifications.data.NotificationPreferencesRepository
+import com.interlinedlist.android.feature.notifications.data.NotificationsRepository
+import javax.inject.Inject
+
+/**
+ * Runs one background-poll pass: fetch the latest notifications, resolve the recipient's
+ * preferences, decide what is NEW and push-enabled (via the pure [NotificationPollProcessor]),
+ * raise the tray notifications, and advance the persisted last-seen marker.
+ *
+ * Holding this outside the `@HiltWorker` keeps it free of the Android `CoroutineWorker`
+ * superclass, so it is fully unit-testable; [NotificationsPollWorker] is a thin adapter
+ * that maps the [Result] onto WorkManager's outcome.
+ */
+class NotificationPollRunner @Inject constructor(
+ private val notificationsRepository: NotificationsRepository,
+ private val preferencesRepository: NotificationPreferencesRepository,
+ private val lastSeenStore: LastSeenNotificationStore,
+ private val raiser: SystemNotificationRaiser,
+) {
+
+ /** The outcome of a poll, mapped by the worker onto WorkManager's Result. */
+ enum class Result {
+ /** Completed (whether or not anything was posted). */
+ SUCCESS,
+
+ /** Transient failure (fetch failed) — the worker should retry with backoff. */
+ RETRY,
+ }
+
+ suspend fun run(): Result {
+ val fetched = when (val result = notificationsRepository.fetchLatest()) {
+ is ApiResult.Success -> result.data
+ is ApiResult.Failure -> return Result.RETRY
+ }
+
+ // Preferences are best-effort: on failure, fall back to no preferences, which
+ // the push filter interprets as default-notify.
+ val preferences = when (val prefs = preferencesRepository.getPreferences()) {
+ is ApiResult.Success -> prefs.data
+ is ApiResult.Failure -> emptyList()
+ }
+
+ val outcome = NotificationPollProcessor.process(
+ fetched = fetched,
+ preferences = preferences,
+ lastSeenId = lastSeenStore.lastSeenId(),
+ hasSeenAny = lastSeenStore.hasSeenAny(),
+ )
+
+ if (outcome.toPost.isNotEmpty()) {
+ raiser.post(outcome.toPost)
+ }
+ // Advance the marker after posting, so a crash before posting doesn't skip items.
+ outcome.newLastSeenId?.let(lastSeenStore::setLastSeenId)
+
+ return Result.SUCCESS
+ }
+}
diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPushFilter.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPushFilter.kt
new file mode 100644
index 0000000..8dc0ae4
--- /dev/null
+++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPushFilter.kt
@@ -0,0 +1,53 @@
+package com.interlinedlist.android.feature.notifications.push
+
+import com.interlinedlist.android.feature.notifications.domain.Notification
+import com.interlinedlist.android.feature.notifications.domain.NotificationChannel
+import com.interlinedlist.android.feature.notifications.domain.NotificationPreference
+
+/**
+ * Pure decision logic that gates which notifications may be raised as a system
+ * (push-tray) notification, based on the recipient's per-event `push` preference.
+ * No Android or IO dependencies, so it is fully unit-testable.
+ *
+ * Mapping is best-effort: a notification's [NotificationType][com.interlinedlist.android.feature.notifications.domain.NotificationType]
+ * is mapped to a [NotificationCategory], whose candidate [NotificationCategory.preferenceKeys]
+ * are matched (case-insensitively) against the fetched preference event keys. The first
+ * matching preference's [NotificationChannel.PUSH] state decides delivery.
+ *
+ * DEFAULT-NOTIFY: when no preference maps to the notification (unknown/unmodelled
+ * event, or preferences could not be fetched), we err on the side of notifying — a
+ * missed alert is worse than an unexpected one, and the recipient can still mute the
+ * channel from system settings.
+ */
+class NotificationPushFilter(
+ preferences: List,
+) {
+
+ /** Preferences indexed by their lower-cased key for O(1), case-insensitive lookup. */
+ private val byKey: Map =
+ preferences.associateBy { it.key.trim().lowercase() }
+
+ /**
+ * Whether [notification] should be raised in the system tray. True when the mapped
+ * `push` preference is enabled, or when nothing maps (default-notify). False only
+ * when a matching preference explicitly disables `push`.
+ */
+ fun shouldNotify(notification: Notification): Boolean {
+ val preference = matchPreference(notification) ?: return true // default-notify
+ // If the event doesn't model a push channel at all, treat it as allowed.
+ if (NotificationChannel.PUSH !in preference.channels) return true
+ return preference.isEnabled(NotificationChannel.PUSH)
+ }
+
+ /** Keeps only the notifications whose `push` channel is enabled (or unmapped). */
+ fun filter(notifications: List): List =
+ notifications.filter(::shouldNotify)
+
+ private fun matchPreference(notification: Notification): NotificationPreference? {
+ val category = NotificationCategory.forType(notification.type)
+ for (candidate in category.preferenceKeys) {
+ byKey[candidate.lowercase()]?.let { return it }
+ }
+ return null
+ }
+}
diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationsPollWorker.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationsPollWorker.kt
new file mode 100644
index 0000000..afa0dea
--- /dev/null
+++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationsPollWorker.kt
@@ -0,0 +1,35 @@
+package com.interlinedlist.android.feature.notifications.push
+
+import android.content.Context
+import androidx.hilt.work.HiltWorker
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+
+/**
+ * Background poll that stands in for FCM push (the product ships no Firebase project).
+ * A thin `@HiltWorker` adapter: it delegates the actual work to the injectable,
+ * unit-tested [NotificationPollRunner] and maps its outcome onto WorkManager's [Result].
+ *
+ * The `HiltWorkerFactory` supplied by `InterlinedListApplication` (Configuration.Provider)
+ * constructs this with its dependencies — no extra bootstrap needed.
+ */
+@HiltWorker
+class NotificationsPollWorker @AssistedInject constructor(
+ @Assisted appContext: Context,
+ @Assisted params: WorkerParameters,
+ private val runner: NotificationPollRunner,
+) : CoroutineWorker(appContext, params) {
+
+ override suspend fun doWork(): Result = when (runner.run()) {
+ NotificationPollRunner.Result.SUCCESS -> Result.success()
+ NotificationPollRunner.Result.RETRY -> Result.retry()
+ }
+
+ companion object {
+ /** Unique names for the scheduled work (see [NotificationsSyncScheduler]). */
+ const val PERIODIC_WORK_NAME = "notifications-poll-periodic"
+ const val ONE_SHOT_WORK_NAME = "notifications-poll-oneshot"
+ }
+}
diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationsSyncScheduler.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationsSyncScheduler.kt
new file mode 100644
index 0000000..ec6c7ed
--- /dev/null
+++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationsSyncScheduler.kt
@@ -0,0 +1,70 @@
+package com.interlinedlist.android.feature.notifications.push
+
+import android.content.Context
+import androidx.work.BackoffPolicy
+import androidx.work.Constraints
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.ExistingWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import java.time.Duration
+
+/**
+ * Schedules the background notification poll. Keep this the single place that enqueues
+ * [NotificationsPollWorker] so the unique-work names and constraints stay consistent
+ * (mirrors `DocumentsSyncScheduler`).
+ *
+ * Wiring (call from the app after sign-in):
+ * ```
+ * NotificationsSyncScheduler.schedulePeriodic(context) // near-real-time tray poll
+ * NotificationsSyncScheduler.syncNow(context) // e.g. right after login
+ * NotificationsSyncScheduler.cancelAll(context) // on sign-out
+ * ```
+ */
+object NotificationsSyncScheduler {
+
+ /** WorkManager's minimum periodic interval; also our chosen cadence. */
+ val MIN_INTERVAL: Duration = Duration.ofMinutes(15)
+
+ private val NETWORK_CONSTRAINTS = Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build()
+
+ /**
+ * Periodic background poll. WorkManager's floor is 15 minutes, so any shorter
+ * [interval] is clamped up to [MIN_INTERVAL].
+ */
+ fun schedulePeriodic(context: Context, interval: Duration = MIN_INTERVAL) {
+ val effective = if (interval < MIN_INTERVAL) MIN_INTERVAL else interval
+ val request = PeriodicWorkRequestBuilder(effective)
+ .setConstraints(NETWORK_CONSTRAINTS)
+ .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, Duration.ofSeconds(30))
+ .build()
+ WorkManager.getInstance(context).enqueueUniquePeriodicWork(
+ NotificationsPollWorker.PERIODIC_WORK_NAME,
+ ExistingPeriodicWorkPolicy.KEEP,
+ request,
+ )
+ }
+
+ /** One-shot poll now — e.g. immediately after login so the marker seeds fast. */
+ fun syncNow(context: Context) {
+ val request = OneTimeWorkRequestBuilder()
+ .setConstraints(NETWORK_CONSTRAINTS)
+ .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, Duration.ofSeconds(15))
+ .build()
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ NotificationsPollWorker.ONE_SHOT_WORK_NAME,
+ ExistingWorkPolicy.REPLACE,
+ request,
+ )
+ }
+
+ /** Cancels all scheduled notification polling (e.g. on sign-out). */
+ fun cancelAll(context: Context) {
+ WorkManager.getInstance(context).cancelUniqueWork(NotificationsPollWorker.PERIODIC_WORK_NAME)
+ WorkManager.getInstance(context).cancelUniqueWork(NotificationsPollWorker.ONE_SHOT_WORK_NAME)
+ }
+}
diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/SystemNotificationChannels.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/SystemNotificationChannels.kt
new file mode 100644
index 0000000..902bf77
--- /dev/null
+++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/SystemNotificationChannels.kt
@@ -0,0 +1,36 @@
+package com.interlinedlist.android.feature.notifications.push
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.os.Build
+import androidx.core.content.getSystemService
+
+/**
+ * Registers the app's system notification channels (one per [NotificationCategory])
+ * on Android O+. Idempotent — creating a channel that already exists is a no-op, and
+ * pre-O is skipped entirely (channels don't exist there). Safe to call at every app
+ * start.
+ *
+ * The decision of WHICH channels to register (their ids/names) is the pure
+ * [NotificationCategory] enum, which is unit-tested; this helper is the thin Android
+ * side-effect that installs them.
+ */
+object SystemNotificationChannels {
+
+ /** Creates every category channel. No-op below Android O. */
+ fun ensureRegistered(context: Context) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ val manager = context.getSystemService() ?: return
+ NotificationCategory.entries.forEach { category ->
+ val channel = NotificationChannel(
+ category.channelId,
+ category.channelName,
+ NotificationManager.IMPORTANCE_DEFAULT,
+ ).apply {
+ description = category.channelDescription
+ }
+ manager.createNotificationChannel(channel)
+ }
+ }
+}
diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/SystemNotificationPoster.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/SystemNotificationPoster.kt
new file mode 100644
index 0000000..b52c5ae
--- /dev/null
+++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/SystemNotificationPoster.kt
@@ -0,0 +1,153 @@
+package com.interlinedlist.android.feature.notifications.push
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import com.interlinedlist.android.feature.notifications.R
+import com.interlinedlist.android.feature.notifications.domain.Notification
+
+/**
+ * Raises system-tray notifications for a batch of new notifications. Abstracted behind
+ * an interface (DIP) so the worker can be unit-tested with a fake, and the real Android
+ * side-effects live only in [SystemNotificationPoster].
+ */
+interface SystemNotificationRaiser {
+ /** Raises tray notifications for [items] (newest-first, already push-filtered). */
+ fun post(items: List)
+}
+
+/**
+ * Posts system-tray notifications for a batch of NEW notifications. The batch is
+ * capped so a large backlog can't spam the tray: at most [MAX_INDIVIDUAL] individual
+ * items are shown, and when there are more, only a single summary is posted.
+ *
+ * Each notification's tap opens the app's launcher activity (resolved via the package
+ * manager, so this module needs no compile-time reference to `MainActivity`) carrying
+ * the [NotificationDeepLink] extras the app reads to route.
+ *
+ * Grouping: individual items share a [GROUP_KEY] and a summary notification anchors the
+ * group so the shade collapses them tidily on Android N+.
+ */
+class SystemNotificationPoster(
+ private val context: Context,
+) : SystemNotificationRaiser {
+
+ /**
+ * Posts [items] (newest-first, already push-filtered). No-op when the list is empty
+ * or the user has notifications disabled at the OS level.
+ */
+ override fun post(items: List) {
+ if (items.isEmpty()) return
+ val manager = NotificationManagerCompat.from(context)
+ if (!manager.areNotificationsEnabled()) return
+
+ if (items.size > MAX_INDIVIDUAL) {
+ postSummaryOnly(manager, items)
+ return
+ }
+
+ items.forEach { notification ->
+ manager.safeNotify(notification.id.hashCode(), buildIndividual(notification))
+ }
+ if (items.size > 1) {
+ manager.safeNotify(SUMMARY_ID, buildGroupSummary(items))
+ }
+ }
+
+ private fun postSummaryOnly(manager: NotificationManagerCompat, items: List) {
+ val category = NotificationCategory.OTHER
+ val summary = NotificationCompat.Builder(context, category.channelId)
+ .setSmallIcon(R.drawable.il_notification_icon)
+ .setContentTitle("$COUNT_TITLE_PREFIX ${items.size} new notifications")
+ .setContentText(items.first().subject)
+ .setAutoCancel(true)
+ .setContentIntent(feedPendingIntent())
+ .build()
+ manager.safeNotify(SUMMARY_ID, summary)
+ }
+
+ private fun buildIndividual(notification: Notification): android.app.Notification {
+ val category = NotificationCategory.forType(notification.type)
+ val title = notification.actorLabel?.let { "$it" } ?: category.channelName
+ return NotificationCompat.Builder(context, category.channelId)
+ .setSmallIcon(R.drawable.il_notification_icon)
+ .setContentTitle(notification.subject.ifBlank { title })
+ .apply { notification.body?.let { setContentText(it) } }
+ .setAutoCancel(true)
+ .setGroup(GROUP_KEY)
+ .setContentIntent(pendingIntentFor(notification))
+ .build()
+ }
+
+ private fun buildGroupSummary(items: List): android.app.Notification =
+ NotificationCompat.Builder(context, NotificationCategory.OTHER.channelId)
+ .setSmallIcon(R.drawable.il_notification_icon)
+ .setContentTitle("$COUNT_TITLE_PREFIX ${items.size} new notifications")
+ .setContentText(items.first().subject)
+ .setGroup(GROUP_KEY)
+ .setGroupSummary(true)
+ .setAutoCancel(true)
+ .setContentIntent(feedPendingIntent())
+ .build()
+
+ // --- intents -----------------------------------------------------------
+
+ /** Launch intent → the app, tagged so the app routes to the specific target. */
+ private fun pendingIntentFor(notification: Notification): PendingIntent {
+ val destination = NotificationDeepLink.destinationFor(notification)
+ val intent = launchIntent().apply {
+ putExtra(NotificationDeepLink.EXTRA_FROM_NOTIFICATION, true)
+ putExtra(NotificationDeepLink.EXTRA_DESTINATION, destination.name)
+ NotificationDeepLink.targetIdFor(notification)?.let {
+ putExtra(NotificationDeepLink.EXTRA_TARGET_ID, it)
+ }
+ }
+ return activityPendingIntent(notification.id.hashCode(), intent)
+ }
+
+ /** Launch intent → the app, routed to the in-app notifications feed (fallback). */
+ private fun feedPendingIntent(): PendingIntent {
+ val intent = launchIntent().apply {
+ putExtra(NotificationDeepLink.EXTRA_FROM_NOTIFICATION, true)
+ putExtra(
+ NotificationDeepLink.EXTRA_DESTINATION,
+ NotificationDeepLink.Destination.NOTIFICATIONS.name,
+ )
+ }
+ return activityPendingIntent(SUMMARY_ID, intent)
+ }
+
+ private fun launchIntent(): Intent =
+ context.packageManager.getLaunchIntentForPackage(context.packageName)
+ ?.apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP }
+ ?: Intent(Intent.ACTION_MAIN).apply { setPackage(context.packageName) }
+
+ private fun activityPendingIntent(requestCode: Int, intent: Intent): PendingIntent =
+ PendingIntent.getActivity(
+ context,
+ requestCode,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
+ private fun NotificationManagerCompat.safeNotify(id: Int, notification: android.app.Notification) {
+ // areNotificationsEnabled() is checked up front; guard the individual post too
+ // so a revoked POST_NOTIFICATIONS permission never throws on the worker thread.
+ runCatching { notify(id, notification) }
+ }
+
+ companion object {
+ /** Max individual notifications before collapsing to a single summary. */
+ const val MAX_INDIVIDUAL = 5
+
+ /** Shared group key so the shade collapses our notifications together. */
+ const val GROUP_KEY = "il.notifications.group"
+
+ /** Stable id for the group-summary / overflow notification. */
+ const val SUMMARY_ID = 424242
+
+ private const val COUNT_TITLE_PREFIX = "InterlinedList:"
+ }
+}
diff --git a/feature/notifications/src/main/res/drawable/il_notification_icon.xml b/feature/notifications/src/main/res/drawable/il_notification_icon.xml
new file mode 100644
index 0000000..611d9fa
--- /dev/null
+++ b/feature/notifications/src/main/res/drawable/il_notification_icon.xml
@@ -0,0 +1,15 @@
+
+
+
+
diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponseTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponseTest.kt
index aa2bd6d..bb8713f 100644
--- a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponseTest.kt
+++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponseTest.kt
@@ -28,6 +28,19 @@ class NotificationsResponseTest {
assertThat(response.items.map { it.id }).containsExactly("a")
}
+ @Test
+ fun `reads the list from the live items key`() {
+ // The production API returns `{ "unreadCount": N, "items": [...] }`. Regression
+ // guard: this key was previously unmapped, silently emptying the notifications
+ // feed and the background push poll.
+ val response = json.decodeFromString(
+ NotificationsResponse.serializer(),
+ """{ "unreadCount": 3, "items": [ { "id": "x" }, { "id": "y" } ] }""",
+ )
+ assertThat(response.items.map { it.id }).containsExactly("x", "y").inOrder()
+ assertThat(response.unreadCount).isEqualTo(3)
+ }
+
@Test
fun `an empty body decodes with sane defaults`() {
val response = json.decodeFromString(NotificationsResponse.serializer(), "{}")
diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/FakeLastSeenNotificationStore.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/FakeLastSeenNotificationStore.kt
new file mode 100644
index 0000000..c919193
--- /dev/null
+++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/FakeLastSeenNotificationStore.kt
@@ -0,0 +1,19 @@
+package com.interlinedlist.android.feature.notifications.push
+
+/** In-memory [LastSeenNotificationStore] for unit tests (no Android dependency). */
+class FakeLastSeenNotificationStore(
+ initial: String? = null,
+) : LastSeenNotificationStore {
+
+ private var lastSeen: String? = initial
+ private var seenAny: Boolean = initial != null
+
+ override fun lastSeenId(): String? = lastSeen
+
+ override fun setLastSeenId(id: String) {
+ lastSeen = id
+ seenAny = true
+ }
+
+ override fun hasSeenAny(): Boolean = seenAny
+}
diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NewNotificationsSelectorTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NewNotificationsSelectorTest.kt
new file mode 100644
index 0000000..14cfa11
--- /dev/null
+++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NewNotificationsSelectorTest.kt
@@ -0,0 +1,88 @@
+package com.interlinedlist.android.feature.notifications.push
+
+import com.google.common.truth.Truth.assertThat
+import com.interlinedlist.android.feature.notifications.domain.Notification
+import com.interlinedlist.android.feature.notifications.domain.NotificationType
+import org.junit.Test
+
+class NewNotificationsSelectorTest {
+
+ private fun notif(id: String) = Notification(
+ id = id,
+ type = NotificationType.FOLLOW,
+ actor = null,
+ subject = "s-$id",
+ body = null,
+ createdAt = null,
+ read = false,
+ target = null,
+ )
+
+ @Test
+ fun `first ever poll surfaces nothing and adopts the newest id as baseline`() {
+ val page = listOf(notif("3"), notif("2"), notif("1"))
+
+ val result = NewNotificationsSelector.select(
+ notifications = page,
+ lastSeenId = null,
+ hasSeenAny = false,
+ )
+
+ assertThat(result.newNotifications).isEmpty()
+ assertThat(result.newLastSeenId).isEqualTo("3")
+ }
+
+ @Test
+ fun `only items newer than the marker are new and marker advances`() {
+ val page = listOf(notif("5"), notif("4"), notif("3"), notif("2"))
+
+ val result = NewNotificationsSelector.select(
+ notifications = page,
+ lastSeenId = "3",
+ hasSeenAny = true,
+ )
+
+ assertThat(result.newNotifications.map { it.id }).containsExactly("5", "4").inOrder()
+ assertThat(result.newLastSeenId).isEqualTo("5")
+ }
+
+ @Test
+ fun `no new items when the newest already equals the marker`() {
+ val page = listOf(notif("9"), notif("8"))
+
+ val result = NewNotificationsSelector.select(
+ notifications = page,
+ lastSeenId = "9",
+ hasSeenAny = true,
+ )
+
+ assertThat(result.newNotifications).isEmpty()
+ assertThat(result.newLastSeenId).isEqualTo("9")
+ }
+
+ @Test
+ fun `marker missing from the page treats the whole page as new`() {
+ val page = listOf(notif("7"), notif("6"))
+
+ val result = NewNotificationsSelector.select(
+ notifications = page,
+ lastSeenId = "1", // fell off the page
+ hasSeenAny = true,
+ )
+
+ assertThat(result.newNotifications.map { it.id }).containsExactly("7", "6").inOrder()
+ assertThat(result.newLastSeenId).isEqualTo("7")
+ }
+
+ @Test
+ fun `empty page is a no-op and keeps the existing marker`() {
+ val result = NewNotificationsSelector.select(
+ notifications = emptyList(),
+ lastSeenId = "4",
+ hasSeenAny = true,
+ )
+
+ assertThat(result.newNotifications).isEmpty()
+ assertThat(result.newLastSeenId).isEqualTo("4")
+ }
+}
diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationCategoryTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationCategoryTest.kt
new file mode 100644
index 0000000..205f7db
--- /dev/null
+++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationCategoryTest.kt
@@ -0,0 +1,48 @@
+package com.interlinedlist.android.feature.notifications.push
+
+import com.google.common.truth.Truth.assertThat
+import com.interlinedlist.android.feature.notifications.domain.NotificationType
+import org.junit.Test
+
+class NotificationCategoryTest {
+
+ @Test
+ fun `every notification type maps to a category`() {
+ // Exhaustive: no type should be unmapped.
+ NotificationType.entries.forEach { type ->
+ val category = NotificationCategory.forType(type)
+ assertThat(category).isNotNull()
+ }
+ }
+
+ @Test
+ fun `core types map to their expected categories`() {
+ assertThat(NotificationCategory.forType(NotificationType.MESSAGE))
+ .isEqualTo(NotificationCategory.MESSAGES)
+ assertThat(NotificationCategory.forType(NotificationType.FOLLOW))
+ .isEqualTo(NotificationCategory.FOLLOWS)
+ assertThat(NotificationCategory.forType(NotificationType.LIKE))
+ .isEqualTo(NotificationCategory.DIGS)
+ assertThat(NotificationCategory.forType(NotificationType.MENTION))
+ .isEqualTo(NotificationCategory.MENTIONS)
+ assertThat(NotificationCategory.forType(NotificationType.REPLY))
+ .isEqualTo(NotificationCategory.REPLIES)
+ }
+
+ @Test
+ fun `list share, system and unknown fall back to OTHER`() {
+ assertThat(NotificationCategory.forType(NotificationType.LIST_SHARE))
+ .isEqualTo(NotificationCategory.OTHER)
+ assertThat(NotificationCategory.forType(NotificationType.SYSTEM))
+ .isEqualTo(NotificationCategory.OTHER)
+ assertThat(NotificationCategory.forType(NotificationType.OTHER))
+ .isEqualTo(NotificationCategory.OTHER)
+ }
+
+ @Test
+ fun `channel ids are unique and non-blank`() {
+ val ids = NotificationCategory.entries.map { it.channelId }
+ assertThat(ids).containsNoDuplicates()
+ assertThat(ids.none { it.isBlank() }).isTrue()
+ }
+}
diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationDeepLinkTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationDeepLinkTest.kt
new file mode 100644
index 0000000..f14a1b5
--- /dev/null
+++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationDeepLinkTest.kt
@@ -0,0 +1,75 @@
+package com.interlinedlist.android.feature.notifications.push
+
+import com.google.common.truth.Truth.assertThat
+import com.interlinedlist.android.feature.notifications.domain.Notification
+import com.interlinedlist.android.feature.notifications.domain.NotificationTarget
+import com.interlinedlist.android.feature.notifications.domain.NotificationTargetKind
+import com.interlinedlist.android.feature.notifications.domain.NotificationType
+import org.junit.Test
+
+class NotificationDeepLinkTest {
+
+ private fun notif(target: NotificationTarget?) = Notification(
+ id = "1",
+ type = NotificationType.OTHER,
+ actor = null,
+ subject = "s",
+ body = null,
+ createdAt = null,
+ read = false,
+ target = target,
+ )
+
+ @Test
+ fun `message target routes to MESSAGE with its id`() {
+ val n = notif(NotificationTarget(NotificationTargetKind.MESSAGE, "m1"))
+
+ assertThat(NotificationDeepLink.destinationFor(n))
+ .isEqualTo(NotificationDeepLink.Destination.MESSAGE)
+ assertThat(NotificationDeepLink.targetIdFor(n)).isEqualTo("m1")
+ }
+
+ @Test
+ fun `user target routes to USER`() {
+ val n = notif(NotificationTarget(NotificationTargetKind.USER, "amy"))
+
+ assertThat(NotificationDeepLink.destinationFor(n))
+ .isEqualTo(NotificationDeepLink.Destination.USER)
+ assertThat(NotificationDeepLink.targetIdFor(n)).isEqualTo("amy")
+ }
+
+ @Test
+ fun `list target routes to LIST`() {
+ val n = notif(NotificationTarget(NotificationTargetKind.LIST, "l9"))
+
+ assertThat(NotificationDeepLink.destinationFor(n))
+ .isEqualTo(NotificationDeepLink.Destination.LIST)
+ assertThat(NotificationDeepLink.targetIdFor(n)).isEqualTo("l9")
+ }
+
+ @Test
+ fun `null target falls back to the notifications feed`() {
+ val n = notif(null)
+
+ assertThat(NotificationDeepLink.destinationFor(n))
+ .isEqualTo(NotificationDeepLink.Destination.NOTIFICATIONS)
+ assertThat(NotificationDeepLink.targetIdFor(n)).isNull()
+ }
+
+ @Test
+ fun `OTHER target kind falls back to the notifications feed`() {
+ val n = notif(NotificationTarget(NotificationTargetKind.OTHER, "x"))
+
+ assertThat(NotificationDeepLink.destinationFor(n))
+ .isEqualTo(NotificationDeepLink.Destination.NOTIFICATIONS)
+ assertThat(NotificationDeepLink.targetIdFor(n)).isNull()
+ }
+
+ @Test
+ fun `blank target id falls back to the notifications feed`() {
+ val n = notif(NotificationTarget(NotificationTargetKind.MESSAGE, " "))
+
+ assertThat(NotificationDeepLink.destinationFor(n))
+ .isEqualTo(NotificationDeepLink.Destination.NOTIFICATIONS)
+ }
+}
diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollProcessorTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollProcessorTest.kt
new file mode 100644
index 0000000..b15213f
--- /dev/null
+++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollProcessorTest.kt
@@ -0,0 +1,102 @@
+package com.interlinedlist.android.feature.notifications.push
+
+import com.google.common.truth.Truth.assertThat
+import com.interlinedlist.android.feature.notifications.domain.Notification
+import com.interlinedlist.android.feature.notifications.domain.NotificationChannel
+import com.interlinedlist.android.feature.notifications.domain.NotificationPreference
+import com.interlinedlist.android.feature.notifications.domain.NotificationType
+import org.junit.Test
+
+class NotificationPollProcessorTest {
+
+ private fun notif(id: String, type: NotificationType) = Notification(
+ id = id,
+ type = type,
+ actor = null,
+ subject = "s-$id",
+ body = null,
+ createdAt = null,
+ read = false,
+ target = null,
+ )
+
+ private fun pref(key: String, push: Boolean) = NotificationPreference(
+ key = key,
+ label = key,
+ description = "",
+ channels = mapOf(NotificationChannel.PUSH to push),
+ )
+
+ @Test
+ fun `first poll posts nothing and seeds the marker`() {
+ val outcome = NotificationPollProcessor.process(
+ fetched = listOf(notif("3", NotificationType.FOLLOW), notif("2", NotificationType.FOLLOW)),
+ preferences = emptyList(),
+ lastSeenId = null,
+ hasSeenAny = false,
+ )
+
+ assertThat(outcome.toPost).isEmpty()
+ assertThat(outcome.newLastSeenId).isEqualTo("3")
+ }
+
+ @Test
+ fun `only new items are posted and the marker advances`() {
+ val outcome = NotificationPollProcessor.process(
+ fetched = listOf(
+ notif("5", NotificationType.FOLLOW),
+ notif("4", NotificationType.MENTION),
+ notif("3", NotificationType.FOLLOW),
+ ),
+ preferences = emptyList(),
+ lastSeenId = "3",
+ hasSeenAny = true,
+ )
+
+ assertThat(outcome.toPost.map { it.id }).containsExactly("5", "4").inOrder()
+ assertThat(outcome.newLastSeenId).isEqualTo("5")
+ }
+
+ @Test
+ fun `push-disabled events are filtered out of the post list`() {
+ val outcome = NotificationPollProcessor.process(
+ fetched = listOf(
+ notif("5", NotificationType.FOLLOW), // follow push disabled -> dropped
+ notif("4", NotificationType.MENTION), // no pref -> default notify
+ ),
+ preferences = listOf(pref("follow", push = false)),
+ lastSeenId = "3",
+ hasSeenAny = true,
+ )
+
+ assertThat(outcome.toPost.map { it.id }).containsExactly("4")
+ // Marker still advances to the true newest, even though "5" wasn't posted.
+ assertThat(outcome.newLastSeenId).isEqualTo("5")
+ }
+
+ @Test
+ fun `no new items is a no-op post but keeps the marker current`() {
+ val outcome = NotificationPollProcessor.process(
+ fetched = listOf(notif("9", NotificationType.FOLLOW)),
+ preferences = emptyList(),
+ lastSeenId = "9",
+ hasSeenAny = true,
+ )
+
+ assertThat(outcome.toPost).isEmpty()
+ assertThat(outcome.newLastSeenId).isEqualTo("9")
+ }
+
+ @Test
+ fun `empty fetch posts nothing`() {
+ val outcome = NotificationPollProcessor.process(
+ fetched = emptyList(),
+ preferences = emptyList(),
+ lastSeenId = "4",
+ hasSeenAny = true,
+ )
+
+ assertThat(outcome.toPost).isEmpty()
+ assertThat(outcome.newLastSeenId).isEqualTo("4")
+ }
+}
diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollRunnerTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollRunnerTest.kt
new file mode 100644
index 0000000..f43c65c
--- /dev/null
+++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollRunnerTest.kt
@@ -0,0 +1,205 @@
+package com.interlinedlist.android.feature.notifications.push
+
+import com.google.common.truth.Truth.assertThat
+import com.interlinedlist.android.core.common.result.ApiResult
+import com.interlinedlist.android.feature.notifications.data.DefaultNotificationsRepository
+import com.interlinedlist.android.feature.notifications.data.FakeNotificationDao
+import com.interlinedlist.android.feature.notifications.data.NotificationPreferencesRepository
+import com.interlinedlist.android.feature.notifications.data.TestDispatcherProvider
+import com.interlinedlist.android.feature.notifications.data.remote.NotificationsApi
+import com.interlinedlist.android.feature.notifications.domain.NotificationChannel
+import com.interlinedlist.android.feature.notifications.domain.NotificationPreference
+import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.json.Json
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import retrofit2.Retrofit
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class NotificationPollRunnerTest {
+
+ private val dispatcher = StandardTestDispatcher()
+ private val json = Json { ignoreUnknownKeys = true; explicitNulls = false }
+
+ private lateinit var server: MockWebServer
+ private lateinit var api: NotificationsApi
+ private lateinit var dao: FakeNotificationDao
+
+ @Before
+ fun setUp() {
+ server = MockWebServer()
+ server.start()
+ val contentType = "application/json".toMediaType()
+ api = Retrofit.Builder()
+ .baseUrl(server.url("/"))
+ .addConverterFactory(json.asConverterFactory(contentType))
+ .build()
+ .create(NotificationsApi::class.java)
+ dao = FakeNotificationDao()
+ }
+
+ @After
+ fun tearDown() = server.shutdown()
+
+ private fun notificationsRepo() = DefaultNotificationsRepository(
+ api = api,
+ notificationDao = dao,
+ json = json,
+ dispatchers = TestDispatcherProvider(dispatcher),
+ )
+
+ /** Preferences repo whose GET result is fixed. */
+ private fun prefsRepo(
+ result: ApiResult> = ApiResult.Success(emptyList()),
+ ): NotificationPreferencesRepository = object : NotificationPreferencesRepository {
+ override suspend fun getPreferences() = result
+ override suspend fun updatePreference(preference: NotificationPreference) =
+ ApiResult.Success(Unit)
+ }
+
+ private fun runner(
+ store: LastSeenNotificationStore,
+ raiser: SystemNotificationRaiser,
+ prefs: NotificationPreferencesRepository = prefsRepo(),
+ ) = NotificationPollRunner(
+ notificationsRepository = notificationsRepo(),
+ preferencesRepository = prefs,
+ lastSeenStore = store,
+ raiser = raiser,
+ )
+
+ private fun enqueue(body: String, code: Int = 200) {
+ server.enqueue(MockResponse().setResponseCode(code).setBody(body))
+ }
+
+ @Test
+ fun `first poll posts nothing and seeds the marker`() = runTest(dispatcher) {
+ enqueue(
+ """{ "data": [ { "id": "3", "type": "follow", "subject": "c" },
+ { "id": "2", "type": "follow", "subject": "b" } ],
+ "pagination": { "hasMore": false } }""",
+ )
+ val store = FakeLastSeenNotificationStore(initial = null)
+ val raiser = RecordingSystemNotificationRaiser()
+
+ val result = runner(store, raiser).run()
+
+ assertThat(result).isEqualTo(NotificationPollRunner.Result.SUCCESS)
+ assertThat(raiser.batches).isEmpty()
+ assertThat(store.lastSeenId()).isEqualTo("3")
+ }
+
+ @Test
+ fun `only new items since last-seen are posted and the marker advances`() = runTest(dispatcher) {
+ enqueue(
+ """{ "data": [ { "id": "5", "type": "mention", "subject": "e" },
+ { "id": "4", "type": "follow", "subject": "d" },
+ { "id": "3", "type": "follow", "subject": "c" } ],
+ "pagination": { "hasMore": false } }""",
+ )
+ val store = FakeLastSeenNotificationStore(initial = "3")
+ val raiser = RecordingSystemNotificationRaiser()
+
+ runner(store, raiser).run()
+
+ assertThat(raiser.postedIds).containsExactly("5", "4").inOrder()
+ assertThat(store.lastSeenId()).isEqualTo("5")
+ }
+
+ @Test
+ fun `push-disabled events are filtered out of the tray`() = runTest(dispatcher) {
+ enqueue(
+ """{ "data": [ { "id": "5", "type": "follow", "subject": "e" },
+ { "id": "4", "type": "mention", "subject": "d" } ],
+ "pagination": { "hasMore": false } }""",
+ )
+ val store = FakeLastSeenNotificationStore(initial = "3")
+ val raiser = RecordingSystemNotificationRaiser()
+ val prefs = prefsRepo(
+ ApiResult.Success(
+ listOf(
+ NotificationPreference(
+ key = "follow",
+ label = "follow",
+ description = "",
+ channels = mapOf(NotificationChannel.PUSH to false),
+ ),
+ ),
+ ),
+ )
+
+ runner(store, raiser, prefs).run()
+
+ // "5" (follow) is dropped; "4" (mention, unmapped) defaults to notify.
+ assertThat(raiser.postedIds).containsExactly("4")
+ // Marker still advances to the true newest.
+ assertThat(store.lastSeenId()).isEqualTo("5")
+ }
+
+ @Test
+ fun `no new items is a no-op post`() = runTest(dispatcher) {
+ enqueue(
+ """{ "data": [ { "id": "9", "type": "follow", "subject": "i" } ],
+ "pagination": { "hasMore": false } }""",
+ )
+ val store = FakeLastSeenNotificationStore(initial = "9")
+ val raiser = RecordingSystemNotificationRaiser()
+
+ runner(store, raiser).run()
+
+ assertThat(raiser.batches).isEmpty()
+ assertThat(store.lastSeenId()).isEqualTo("9")
+ }
+
+ @Test
+ fun `empty page posts nothing and keeps the marker`() = runTest(dispatcher) {
+ enqueue("""{ "data": [], "pagination": { "hasMore": false } }""")
+ val store = FakeLastSeenNotificationStore(initial = "4")
+ val raiser = RecordingSystemNotificationRaiser()
+
+ runner(store, raiser).run()
+
+ assertThat(raiser.batches).isEmpty()
+ assertThat(store.lastSeenId()).isEqualTo("4")
+ }
+
+ @Test
+ fun `fetch failure asks WorkManager to retry`() = runTest(dispatcher) {
+ enqueue("""{ "error": "boom" }""", code = 500)
+ val store = FakeLastSeenNotificationStore(initial = "4")
+ val raiser = RecordingSystemNotificationRaiser()
+
+ val result = runner(store, raiser).run()
+
+ assertThat(result).isEqualTo(NotificationPollRunner.Result.RETRY)
+ assertThat(raiser.batches).isEmpty()
+ // Marker untouched on failure.
+ assertThat(store.lastSeenId()).isEqualTo("4")
+ }
+
+ @Test
+ fun `preferences failure falls back to default-notify`() = runTest(dispatcher) {
+ enqueue(
+ """{ "data": [ { "id": "5", "type": "follow", "subject": "e" } ],
+ "pagination": { "hasMore": false } }""",
+ )
+ val store = FakeLastSeenNotificationStore(initial = "3")
+ val raiser = RecordingSystemNotificationRaiser()
+ val prefs = prefsRepo(
+ ApiResult.Failure(com.interlinedlist.android.core.common.result.AppError.Unknown("x")),
+ )
+
+ runner(store, raiser, prefs).run()
+
+ // Preferences unavailable -> default notify -> "5" is posted.
+ assertThat(raiser.postedIds).containsExactly("5")
+ assertThat(store.lastSeenId()).isEqualTo("5")
+ }
+}
diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPushFilterTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPushFilterTest.kt
new file mode 100644
index 0000000..33e293e
--- /dev/null
+++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPushFilterTest.kt
@@ -0,0 +1,105 @@
+package com.interlinedlist.android.feature.notifications.push
+
+import com.google.common.truth.Truth.assertThat
+import com.interlinedlist.android.feature.notifications.domain.Notification
+import com.interlinedlist.android.feature.notifications.domain.NotificationChannel
+import com.interlinedlist.android.feature.notifications.domain.NotificationPreference
+import com.interlinedlist.android.feature.notifications.domain.NotificationType
+import org.junit.Test
+
+class NotificationPushFilterTest {
+
+ private fun notif(id: String, type: NotificationType) = Notification(
+ id = id,
+ type = type,
+ actor = null,
+ subject = "s",
+ body = null,
+ createdAt = null,
+ read = false,
+ target = null,
+ )
+
+ private fun pref(key: String, push: Boolean) = NotificationPreference(
+ key = key,
+ label = key,
+ description = "",
+ channels = mapOf(
+ NotificationChannel.PUSH to push,
+ NotificationChannel.IN_APP to true,
+ ),
+ )
+
+ @Test
+ fun `push-disabled event is filtered out`() {
+ val filter = NotificationPushFilter(listOf(pref("follow", push = false)))
+
+ assertThat(filter.shouldNotify(notif("1", NotificationType.FOLLOW))).isFalse()
+ }
+
+ @Test
+ fun `push-enabled event is kept`() {
+ val filter = NotificationPushFilter(listOf(pref("follow", push = true)))
+
+ assertThat(filter.shouldNotify(notif("1", NotificationType.FOLLOW))).isTrue()
+ }
+
+ @Test
+ fun `key matching is case-insensitive`() {
+ val filter = NotificationPushFilter(listOf(pref("FoLLoW", push = false)))
+
+ assertThat(filter.shouldNotify(notif("1", NotificationType.FOLLOW))).isFalse()
+ }
+
+ @Test
+ fun `unmapped event defaults to notify`() {
+ // No preference for the DIG/like category at all.
+ val filter = NotificationPushFilter(listOf(pref("follow", push = false)))
+
+ assertThat(filter.shouldNotify(notif("1", NotificationType.LIKE))).isTrue()
+ }
+
+ @Test
+ fun `empty preferences default to notify`() {
+ val filter = NotificationPushFilter(emptyList())
+
+ assertThat(filter.shouldNotify(notif("1", NotificationType.MENTION))).isTrue()
+ }
+
+ @Test
+ fun `event without a push channel is treated as allowed`() {
+ val emailOnly = NotificationPreference(
+ key = "follow",
+ label = "follow",
+ description = "",
+ channels = mapOf(NotificationChannel.EMAIL to true), // no PUSH modelled
+ )
+ val filter = NotificationPushFilter(listOf(emailOnly))
+
+ assertThat(filter.shouldNotify(notif("1", NotificationType.FOLLOW))).isTrue()
+ }
+
+ @Test
+ fun `filter keeps only push-enabled notifications`() {
+ val filter = NotificationPushFilter(
+ listOf(
+ pref("follow", push = true),
+ pref("dig", push = false),
+ ),
+ )
+ val input = listOf(
+ notif("1", NotificationType.FOLLOW), // kept
+ notif("2", NotificationType.LIKE), // dropped (dig disabled)
+ notif("3", NotificationType.MENTION), // kept (unmapped -> default notify)
+ )
+
+ assertThat(filter.filter(input).map { it.id }).containsExactly("1", "3").inOrder()
+ }
+
+ @Test
+ fun `dig preference maps to the like notification type`() {
+ val filter = NotificationPushFilter(listOf(pref("dig", push = false)))
+
+ assertThat(filter.shouldNotify(notif("1", NotificationType.LIKE))).isFalse()
+ }
+}
diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/RecordingSystemNotificationRaiser.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/RecordingSystemNotificationRaiser.kt
new file mode 100644
index 0000000..af9166e
--- /dev/null
+++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/RecordingSystemNotificationRaiser.kt
@@ -0,0 +1,17 @@
+package com.interlinedlist.android.feature.notifications.push
+
+import com.interlinedlist.android.feature.notifications.domain.Notification
+
+/** Records every [post] call so tests can assert which notifications were raised. */
+class RecordingSystemNotificationRaiser : SystemNotificationRaiser {
+
+ /** Each element is the batch handed to one [post] call. */
+ val batches = mutableListOf>()
+
+ /** Flattened ids across all batches, in order. */
+ val postedIds: List get() = batches.flatten().map { it.id }
+
+ override fun post(items: List) {
+ batches += items
+ }
+}
diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/FakeNotificationsRepository.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/FakeNotificationsRepository.kt
index a585ccf..49d3fb9 100644
--- a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/FakeNotificationsRepository.kt
+++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/FakeNotificationsRepository.kt
@@ -18,12 +18,14 @@ class FakeNotificationsRepository : NotificationsRepository {
private val notifications = MutableStateFlow>(emptyList())
+ var fetchLatestResult: ApiResult> = ApiResult.Success(emptyList())
var refreshResult: ApiResult = ApiResult.Success(false)
var loadMoreResult: ApiResult = ApiResult.Success(false)
var markReadResult: ApiResult = ApiResult.Success(Unit)
var markAllReadResult: ApiResult = ApiResult.Success(Unit)
var dismissResult: ApiResult = ApiResult.Success(Unit)
+ var fetchLatestCount = 0
var refreshCount = 0
var loadMoreCount = 0
var markReadIds = mutableListOf()
@@ -37,6 +39,11 @@ class FakeNotificationsRepository : NotificationsRepository {
override fun observeUnreadCount(): Flow =
notifications.map { list -> list.count { !it.read } }
+ override suspend fun fetchLatest(): ApiResult> {
+ fetchLatestCount++
+ return fetchLatestResult
+ }
+
override suspend fun refresh(): ApiResult {
refreshCount++
return refreshResult