From bc53fd7c7f93405fc6c774df276cbe3ceb581879 Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 13 Jun 2026 05:12:56 +0200 Subject: [PATCH] feat(client): direct message UI on Android (beta blocker 4/5) Add a direct-message conversation list to the Android client. The home screen gains an envelope action that opens a list of DMs showing each conversation's avatar, title, last-message preview, and unread badge. Tapping a conversation opens it through the existing text channel screen (a DM is just a channel, so its messages flow through the message API by channel id). New pieces: - DmConversation/DmParticipant/LastMessagePreview models matching the flattened GET /api/dm response (SnakeCase JSON, unknown channel fields ignored) - DmApi (getDms + markAsRead) + DmModule @Binds, following the MessageApi pattern - DmRepository with pure, unit-tested helpers for conversation title and avatar resolution (1:1 vs group) and unread clearing - DmListScreen + DmListViewModel; opening a DM marks it read - "dms" nav route + envelope entry point in the home top bar Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + .../java/io/wolftown/kaiku/data/api/DmApi.kt | 41 +++++ .../kaiku/data/repository/DmRepository.kt | 67 +++++++ .../java/io/wolftown/kaiku/di/DmModule.kt | 18 ++ .../kaiku/domain/model/DmConversation.kt | 43 +++++ .../io/wolftown/kaiku/ui/KaikuNavGraph.kt | 13 ++ .../io/wolftown/kaiku/ui/dm/DmListScreen.kt | 172 ++++++++++++++++++ .../wolftown/kaiku/ui/dm/DmListViewModel.kt | 76 ++++++++ .../io/wolftown/kaiku/ui/home/HomeScreen.kt | 5 + .../kaiku/domain/DmConversationTest.kt | 129 +++++++++++++ 10 files changed, 565 insertions(+) create mode 100644 mobile/android/app/src/main/java/io/wolftown/kaiku/data/api/DmApi.kt create mode 100644 mobile/android/app/src/main/java/io/wolftown/kaiku/data/repository/DmRepository.kt create mode 100644 mobile/android/app/src/main/java/io/wolftown/kaiku/di/DmModule.kt create mode 100644 mobile/android/app/src/main/java/io/wolftown/kaiku/domain/model/DmConversation.kt create mode 100644 mobile/android/app/src/main/java/io/wolftown/kaiku/ui/dm/DmListScreen.kt create mode 100644 mobile/android/app/src/main/java/io/wolftown/kaiku/ui/dm/DmListViewModel.kt create mode 100644 mobile/android/app/src/test/java/io/wolftown/kaiku/domain/DmConversationTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index af6d6c82..fcf26cad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Release note structure source: `docs/project/RELEASE_NOTES_TEMPLATE.md` ### Added +- Android: direct messages are now accessible — a new envelope button on the home screen opens a conversation list (avatar, name, last-message preview, unread badge); tapping a conversation opens it like any channel - Android: message attachments now display — images render as inline thumbnails, other files as a chip with name and size (previously attachments were invisible) - Android: unread channels now show a count badge and bolder name in the channel list, clearing when you open the channel and re-raising on new messages - Android: messages now render inline markdown (bold, italic, strikethrough, inline code, and click-to-reveal spoilers) instead of plain text diff --git a/mobile/android/app/src/main/java/io/wolftown/kaiku/data/api/DmApi.kt b/mobile/android/app/src/main/java/io/wolftown/kaiku/data/api/DmApi.kt new file mode 100644 index 00000000..6e1a4841 --- /dev/null +++ b/mobile/android/app/src/main/java/io/wolftown/kaiku/data/api/DmApi.kt @@ -0,0 +1,41 @@ +package io.wolftown.kaiku.data.api + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.wolftown.kaiku.domain.model.DmConversation +import javax.inject.Inject + +interface DmApi { + /** List the current user's DM conversations, newest activity first. */ + suspend fun getDms(): List + + /** Mark a DM as read up to its latest message, clearing the unread count. */ + suspend fun markAsRead(channelId: String) +} + +class DmApiImpl @Inject constructor( + private val httpClient: HttpClient +) : DmApi { + + override suspend fun getDms(): List { + val response = httpClient.get("/api/dm") + + if (!response.status.isSuccess()) { + val errorBody = runCatching { response.body() }.getOrNull() + throw ApiException(response.status, errorBody?.message ?: "Failed to load direct messages") + } + + return response.body() + } + + override suspend fun markAsRead(channelId: String) { + val response = httpClient.post("/api/dm/$channelId/read") + + if (!response.status.isSuccess()) { + val errorBody = runCatching { response.body() }.getOrNull() + throw ApiException(response.status, errorBody?.message ?: "Failed to mark DM as read") + } + } +} diff --git a/mobile/android/app/src/main/java/io/wolftown/kaiku/data/repository/DmRepository.kt b/mobile/android/app/src/main/java/io/wolftown/kaiku/data/repository/DmRepository.kt new file mode 100644 index 00000000..66b6505b --- /dev/null +++ b/mobile/android/app/src/main/java/io/wolftown/kaiku/data/repository/DmRepository.kt @@ -0,0 +1,67 @@ +package io.wolftown.kaiku.data.repository + +import io.wolftown.kaiku.data.api.DmApi +import io.wolftown.kaiku.domain.model.DmConversation +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Holds the user's direct-message conversation list. + * + * The list is fetched from the REST API on demand; opening a DM marks it read + * locally (and on the server) so its unread badge clears immediately. + */ +@Singleton +class DmRepository @Inject constructor( + private val dmApi: DmApi, +) { + private val _conversations = MutableStateFlow>(emptyList()) + val conversations: StateFlow> = _conversations.asStateFlow() + + suspend fun loadDms() { + _conversations.value = dmApi.getDms() + } + + /** + * Mark a DM read: clears its badge locally, then tells the server. The + * local clear is optimistic so the UI updates even if the network is slow; + * a server failure is surfaced to the caller. + */ + suspend fun markAsRead(channelId: String) { + _conversations.value = clearUnread(_conversations.value, channelId) + dmApi.markAsRead(channelId) + } + + companion object { + /** Reset unread for one conversation in the list (pure, testable). */ + fun clearUnread(conversations: List, channelId: String): List = + conversations.map { if (it.id == channelId) it.copy(unreadCount = 0) else it } + + /** + * Title to show for a conversation from the current user's perspective: + * - 1:1 DM → the other participant's display name + * - group DM → the explicit channel name, falling back to the joined + * names of the other participants + */ + fun conversationTitle(dm: DmConversation, currentUserId: String): String { + val others = dm.participants.filter { it.userId != currentUserId } + return when { + others.size > 1 -> dm.name.ifBlank { others.joinToString(", ") { it.displayName } } + others.size == 1 -> others.first().displayName + else -> dm.name.ifBlank { "Direct Message" } + } + } + + /** + * Avatar to show for a conversation: the other participant's avatar for + * a 1:1 DM, or the group icon for a multi-party DM. Null if neither is set. + */ + fun conversationAvatarUrl(dm: DmConversation, currentUserId: String): String? { + val others = dm.participants.filter { it.userId != currentUserId } + return if (others.size == 1) others.first().avatarUrl else dm.iconUrl + } + } +} diff --git a/mobile/android/app/src/main/java/io/wolftown/kaiku/di/DmModule.kt b/mobile/android/app/src/main/java/io/wolftown/kaiku/di/DmModule.kt new file mode 100644 index 00000000..a30b067e --- /dev/null +++ b/mobile/android/app/src/main/java/io/wolftown/kaiku/di/DmModule.kt @@ -0,0 +1,18 @@ +package io.wolftown.kaiku.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import io.wolftown.kaiku.data.api.DmApi +import io.wolftown.kaiku.data.api.DmApiImpl +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class DmModule { + + @Binds + @Singleton + abstract fun bindDmApi(impl: DmApiImpl): DmApi +} diff --git a/mobile/android/app/src/main/java/io/wolftown/kaiku/domain/model/DmConversation.kt b/mobile/android/app/src/main/java/io/wolftown/kaiku/domain/model/DmConversation.kt new file mode 100644 index 00000000..1d50078f --- /dev/null +++ b/mobile/android/app/src/main/java/io/wolftown/kaiku/domain/model/DmConversation.kt @@ -0,0 +1,43 @@ +package io.wolftown.kaiku.domain.model + +import kotlinx.serialization.Serializable + +/** + * A direct-message conversation as returned by `GET /api/dm`. + * + * The server flattens the underlying channel fields (id, name, channel_type, + * icon_url, …) into this object alongside the DM-specific participants, last + * message preview, and unread count. Unmodelled channel fields are dropped by + * the JSON parser ([io.wolftown.kaiku.data.KaikuJson] has `ignoreUnknownKeys`). + * + * A DM *is* a channel, so its messages are loaded through the normal message + * API using [id] as the channel id. + */ +@Serializable +data class DmConversation( + val id: String, + val name: String = "", + val channelType: ChannelType = ChannelType.DM, + val iconUrl: String? = null, + val participants: List = emptyList(), + val lastMessage: LastMessagePreview? = null, + val unreadCount: Int = 0, +) + +@Serializable +data class DmParticipant( + val userId: String, + val username: String, + val displayName: String, + val avatarUrl: String? = null, + val joinedAt: String = "", +) + +@Serializable +data class LastMessagePreview( + val id: String, + val content: String, + val userId: String? = null, + val username: String? = null, + val createdAt: String = "", +) diff --git a/mobile/android/app/src/main/java/io/wolftown/kaiku/ui/KaikuNavGraph.kt b/mobile/android/app/src/main/java/io/wolftown/kaiku/ui/KaikuNavGraph.kt index 555a7e74..5924acda 100644 --- a/mobile/android/app/src/main/java/io/wolftown/kaiku/ui/KaikuNavGraph.kt +++ b/mobile/android/app/src/main/java/io/wolftown/kaiku/ui/KaikuNavGraph.kt @@ -23,6 +23,7 @@ import io.wolftown.kaiku.ui.auth.QrScannerScreen import io.wolftown.kaiku.ui.auth.RegisterScreen import io.wolftown.kaiku.ui.auth.ServerUrlScreen import io.wolftown.kaiku.ui.channel.TextChannelScreen +import io.wolftown.kaiku.ui.dm.DmListScreen import io.wolftown.kaiku.ui.home.HomeScreen import io.wolftown.kaiku.ui.settings.SettingsScreen import io.wolftown.kaiku.ui.voice.VoiceChannelScreen @@ -109,10 +110,22 @@ fun KaikuNavGraph( }, onNavigateToSettings = { navController.navigate("settings") + }, + onNavigateToDms = { + navController.navigate("dms") } ) } + composable("dms") { + DmListScreen( + onOpenDm = { channelId -> + navController.navigate("channel/$channelId") + }, + onNavigateBack = { navController.popBackStack() } + ) + } + composable("channel/{channelId}") { TextChannelScreen( currentUserId = currentUserId ?: "", diff --git a/mobile/android/app/src/main/java/io/wolftown/kaiku/ui/dm/DmListScreen.kt b/mobile/android/app/src/main/java/io/wolftown/kaiku/ui/dm/DmListScreen.kt new file mode 100644 index 00000000..a3538e42 --- /dev/null +++ b/mobile/android/app/src/main/java/io/wolftown/kaiku/ui/dm/DmListScreen.kt @@ -0,0 +1,172 @@ +package io.wolftown.kaiku.ui.dm + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import coil3.compose.AsyncImage +import io.wolftown.kaiku.data.repository.DmRepository +import io.wolftown.kaiku.domain.model.DmConversation + +/** + * Direct-message conversation list. + * + * Each row shows the conversation avatar, its title (the other participant for + * a 1:1 DM, or the group name), a one-line last-message preview, and an unread + * badge. Tapping a row opens the DM, which is just a channel — [onOpenDm] + * navigates to the existing text channel screen by channel id. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DmListScreen( + onOpenDm: (channelId: String) -> Unit, + onNavigateBack: () -> Unit, + viewModel: DmListViewModel = hiltViewModel() +) { + val conversations by viewModel.conversations.collectAsState() + val currentUserId by viewModel.currentUserId.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + val error by viewModel.error.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Direct Messages") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + } + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + when { + isLoading && conversations.isEmpty() -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + + error != null && conversations.isEmpty() -> { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = error ?: "An error occurred", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { viewModel.refresh() }) { + Text("Retry") + } + } + } + + conversations.isEmpty() -> { + Text( + text = "No direct messages yet", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.Center) + ) + } + + else -> { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(conversations, key = { it.id }) { dm -> + DmConversationRow( + dm = dm, + currentUserId = currentUserId, + onClick = { + viewModel.onOpenConversation(dm.id) + onOpenDm(dm.id) + } + ) + HorizontalDivider() + } + } + } + } + } + } +} + +@Composable +private fun DmConversationRow( + dm: DmConversation, + currentUserId: String, + onClick: () -> Unit +) { + val title = DmRepository.conversationTitle(dm, currentUserId) + val avatarUrl = DmRepository.conversationAvatarUrl(dm, currentUserId) + val hasUnread = dm.unreadCount > 0 + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = avatarUrl, + contentDescription = "$title avatar", + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (hasUnread) FontWeight.SemiBold else FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + val preview = dm.lastMessage?.let { last -> + val sender = last.username?.let { "$it: " } ?: "" + "$sender${last.content}" + } + if (preview != null) { + Text( + text = preview, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + if (hasUnread) { + Spacer(modifier = Modifier.width(8.dp)) + Badge { + Text(text = if (dm.unreadCount > 99) "99+" else dm.unreadCount.toString()) + } + } + } +} diff --git a/mobile/android/app/src/main/java/io/wolftown/kaiku/ui/dm/DmListViewModel.kt b/mobile/android/app/src/main/java/io/wolftown/kaiku/ui/dm/DmListViewModel.kt new file mode 100644 index 00000000..7240617c --- /dev/null +++ b/mobile/android/app/src/main/java/io/wolftown/kaiku/ui/dm/DmListViewModel.kt @@ -0,0 +1,76 @@ +package io.wolftown.kaiku.ui.dm + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.wolftown.kaiku.data.local.AuthState +import io.wolftown.kaiku.data.repository.DmRepository +import io.wolftown.kaiku.domain.model.DmConversation +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.util.logging.Level +import java.util.logging.Logger +import kotlin.coroutines.cancellation.CancellationException +import javax.inject.Inject + +@HiltViewModel +class DmListViewModel @Inject constructor( + private val dmRepository: DmRepository, + authState: AuthState, +) : ViewModel() { + + companion object { + private val logger = Logger.getLogger("DmListViewModel") + } + + val conversations: StateFlow> = dmRepository.conversations + + val currentUserId: StateFlow = authState.currentUserId + .map { it ?: "" } + .stateIn(viewModelScope, SharingStarted.Eagerly, "") + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + init { + refresh() + } + + fun refresh() { + _isLoading.value = true + _error.value = null + viewModelScope.launch { + try { + dmRepository.loadDms() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _error.value = "Failed to load direct messages" + logger.log(Level.WARNING, "Failed to load DMs", e) + } finally { + _isLoading.value = false + } + } + } + + /** Opening a DM clears its unread badge (best-effort; failures are logged). */ + fun onOpenConversation(channelId: String) { + viewModelScope.launch { + try { + dmRepository.markAsRead(channelId) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.log(Level.WARNING, "Failed to mark DM $channelId as read", e) + } + } + } +} diff --git a/mobile/android/app/src/main/java/io/wolftown/kaiku/ui/home/HomeScreen.kt b/mobile/android/app/src/main/java/io/wolftown/kaiku/ui/home/HomeScreen.kt index f689e348..0b2e95ec 100644 --- a/mobile/android/app/src/main/java/io/wolftown/kaiku/ui/home/HomeScreen.kt +++ b/mobile/android/app/src/main/java/io/wolftown/kaiku/ui/home/HomeScreen.kt @@ -2,6 +2,7 @@ package io.wolftown.kaiku.ui.home import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings @@ -20,6 +21,7 @@ fun HomeScreen( onNavigateToTextChannel: (channelId: String) -> Unit, onNavigateToVoiceChannel: (channelId: String) -> Unit, onNavigateToSettings: () -> Unit = {}, + onNavigateToDms: () -> Unit = {}, viewModel: HomeViewModel = hiltViewModel() ) { val guilds by viewModel.guilds.collectAsState() @@ -70,6 +72,9 @@ fun HomeScreen( } }, actions = { + IconButton(onClick = onNavigateToDms) { + Icon(Icons.Default.Email, contentDescription = "Direct messages") + } IconButton(onClick = { viewModel.refresh() }) { Icon(Icons.Default.Refresh, contentDescription = "Refresh") } diff --git a/mobile/android/app/src/test/java/io/wolftown/kaiku/domain/DmConversationTest.kt b/mobile/android/app/src/test/java/io/wolftown/kaiku/domain/DmConversationTest.kt new file mode 100644 index 00000000..f1cfef2d --- /dev/null +++ b/mobile/android/app/src/test/java/io/wolftown/kaiku/domain/DmConversationTest.kt @@ -0,0 +1,129 @@ +package io.wolftown.kaiku.domain + +import io.wolftown.kaiku.data.repository.DmRepository +import io.wolftown.kaiku.domain.model.DmConversation +import io.wolftown.kaiku.domain.model.DmParticipant +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DmConversationTest { + + private val me = "user-me" + + private fun participant(id: String, name: String, avatar: String? = null) = DmParticipant( + userId = id, + username = name.lowercase(), + displayName = name, + avatarUrl = avatar, + ) + + private fun dm( + id: String = "dm1", + name: String = "", + icon: String? = null, + unread: Int = 0, + participants: List, + ) = DmConversation( + id = id, + name = name, + iconUrl = icon, + unreadCount = unread, + participants = participants, + ) + + @Test + fun conversationTitle_oneOnOne_usesOtherParticipant() { + val conv = dm(participants = listOf(participant(me, "Me"), participant("u2", "Alice"))) + assertEquals("Alice", DmRepository.conversationTitle(conv, me)) + } + + @Test + fun conversationTitle_oneOnOne_ignoresChannelName() { + // A 1:1 DM should always show the other person, even if a name leaked in. + val conv = dm( + name = "auto-generated", + participants = listOf(participant(me, "Me"), participant("u2", "Alice")), + ) + assertEquals("Alice", DmRepository.conversationTitle(conv, me)) + } + + @Test + fun conversationTitle_groupWithName_usesName() { + val conv = dm( + name = "Squad", + participants = listOf( + participant(me, "Me"), + participant("u2", "Alice"), + participant("u3", "Bob"), + ), + ) + assertEquals("Squad", DmRepository.conversationTitle(conv, me)) + } + + @Test + fun conversationTitle_groupWithoutName_joinsOtherNames() { + val conv = dm( + participants = listOf( + participant(me, "Me"), + participant("u2", "Alice"), + participant("u3", "Bob"), + ), + ) + assertEquals("Alice, Bob", DmRepository.conversationTitle(conv, me)) + } + + @Test + fun conversationTitle_selfOnly_fallsBack() { + val conv = dm(participants = listOf(participant(me, "Me"))) + assertEquals("Direct Message", DmRepository.conversationTitle(conv, me)) + } + + @Test + fun conversationAvatar_oneOnOne_usesOtherParticipantAvatar() { + val conv = dm( + icon = "/group/icon", + participants = listOf( + participant(me, "Me", "/me.png"), + participant("u2", "Alice", "/alice.png"), + ), + ) + assertEquals("/alice.png", DmRepository.conversationAvatarUrl(conv, me)) + } + + @Test + fun conversationAvatar_group_usesGroupIcon() { + val conv = dm( + icon = "/group/icon", + participants = listOf( + participant(me, "Me"), + participant("u2", "Alice", "/alice.png"), + participant("u3", "Bob", "/bob.png"), + ), + ) + assertEquals("/group/icon", DmRepository.conversationAvatarUrl(conv, me)) + } + + @Test + fun conversationAvatar_groupWithoutIcon_isNull() { + val conv = dm( + participants = listOf( + participant(me, "Me"), + participant("u2", "Alice"), + participant("u3", "Bob"), + ), + ) + assertNull(DmRepository.conversationAvatarUrl(conv, me)) + } + + @Test + fun clearUnread_resetsOnlyTheTargetConversation() { + val before = listOf( + dm(id = "a", unread = 5, participants = listOf(participant(me, "Me"), participant("u2", "Alice"))), + dm(id = "b", unread = 3, participants = listOf(participant(me, "Me"), participant("u3", "Bob"))), + ) + val after = DmRepository.clearUnread(before, "a") + assertEquals(0, after.first { it.id == "a" }.unreadCount) + assertEquals(3, after.first { it.id == "b" }.unreadCount) + } +}