diff --git a/feature/directmessages/src/androidTest/kotlin/com/interlinedlist/android/feature/directmessages/ui/InboxScreenTest.kt b/feature/directmessages/src/androidTest/kotlin/com/interlinedlist/android/feature/directmessages/ui/InboxScreenTest.kt index ab9e197..fa42439 100644 --- a/feature/directmessages/src/androidTest/kotlin/com/interlinedlist/android/feature/directmessages/ui/InboxScreenTest.kt +++ b/feature/directmessages/src/androidTest/kotlin/com/interlinedlist/android/feature/directmessages/ui/InboxScreenTest.kt @@ -22,7 +22,8 @@ class InboxScreenTest { private fun conversation(username: String, unread: Boolean) = Conversation( username = username, displayName = "Adron Hall", avatarUrl = null, - lastMessageBody = "hey there", lastMessageAtMillis = 1L, hasUnread = unread, + lastMessageBody = "hey there", lastMessageAtMillis = 1L, + unreadCount = if (unread) 1 else 0, ) @Test diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DefaultDirectMessagesRepository.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DefaultDirectMessagesRepository.kt index 0f3c0af..b86d9fd 100644 --- a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DefaultDirectMessagesRepository.kt +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DefaultDirectMessagesRepository.kt @@ -5,11 +5,9 @@ import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.map import com.interlinedlist.android.core.network.error.safeApiCall import com.interlinedlist.android.feature.directmessages.data.local.ConversationDao -import com.interlinedlist.android.feature.directmessages.data.local.ConversationEntity import com.interlinedlist.android.feature.directmessages.data.local.DirectMessageDao import com.interlinedlist.android.feature.directmessages.data.local.DirectMessageEntity import com.interlinedlist.android.feature.directmessages.data.remote.DirectMessagesApi -import com.interlinedlist.android.feature.directmessages.data.remote.dto.MessageDto import com.interlinedlist.android.feature.directmessages.data.remote.dto.RecipientDto import com.interlinedlist.android.feature.directmessages.data.remote.dto.SendMessageRequest import kotlinx.coroutines.flow.Flow @@ -43,11 +41,12 @@ class DefaultDirectMessagesRepository @Inject constructor( override suspend fun refreshInbox(cursor: String?): ApiResult = withContext(dispatchers.io) { - safeApiCall(json) { api.getInbox(cursor = cursor) }.let { result -> + safeApiCall(json) { api.getConversations(cursor = cursor) }.let { result -> when (result) { is ApiResult.Success -> { - val summaries = result.data.items.mapNotNull { it.toConversationSummary() } - if (summaries.isNotEmpty()) conversationDao.upsertAll(summaries) + val me = currentUserId + val rows = result.data.rows.mapNotNull { it.toEntity(me) } + if (rows.isNotEmpty()) conversationDao.upsertAll(rows) ApiResult.Success(result.data.nextCursor) } is ApiResult.Failure -> result @@ -119,7 +118,7 @@ class DefaultDirectMessagesRepository @Inject constructor( ) when (val result = safeApiCall(json) { api.send(request) }) { is ApiResult.Success -> { - val serverMessage = result.data.message() + val serverMessage = result.data.createdMessage() if (serverMessage != null && serverMessage.id.isNotBlank()) { messageDao.deleteById(optimisticId) val entity = serverMessage.toEntity(username) @@ -181,26 +180,4 @@ class DefaultDirectMessagesRepository @Inject constructor( override suspend fun unreadCount(): ApiResult = withContext(dispatchers.io) { safeApiCall(json) { api.getUnreadCount() }.map { it.count } } - - /** - * Folds an inbox message into a conversation summary. The other participant - * is whichever end of the message is not the current user; the embedded - * author sub-object (present on the inbox endpoint) supplies display info. - */ - private fun MessageDto.toConversationSummary(): ConversationEntity? { - val me = currentUserId - val other = embeddedAuthor - val otherUsername = other?.username - ?: return null // Without a username we cannot key the conversation. - val received = me != null && recipientId == me - return ConversationEntity( - username = otherUsername, - displayName = other.displayName, - avatarUrl = other.avatar, - lastMessageId = id, - lastMessageBody = body, - lastMessageAtMillis = parseIsoMillis(createdAt), - hasUnread = received && readAt == null, - ) - } } diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DirectMessagesRepository.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DirectMessagesRepository.kt index cfaca94..6275be0 100644 --- a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DirectMessagesRepository.kt +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DirectMessagesRepository.kt @@ -13,15 +13,17 @@ interface DirectMessagesRepository { /** The signed-in user's id, used to tell "mine" from "theirs" in a thread. */ val currentUserId: String? - /** Newest-first stream of cached conversation summaries. */ + /** Newest-first stream of cached conversations, one row per conversation. */ fun observeConversations(): Flow> /** Oldest-first stream of a single conversation's cached messages. */ fun observeThread(username: String): Flow> /** - * Refreshes one page of the inbox. Pass `null` to load the first page; pass - * the previous result's cursor to page. Returns the next cursor (or null). + * Refreshes one page of the inbox from `GET /api/dm/conversations`. Pass + * `null` to load the first page; pass the previous result's cursor to page. + * Pages are merged into the cache, so paging appends. Returns the next + * cursor (or null when the last page has been reached). */ suspend fun refreshInbox(cursor: String? = null): ApiResult diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DmMappers.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DmMappers.kt index c999b1f..18eb009 100644 --- a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DmMappers.kt +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DmMappers.kt @@ -2,6 +2,7 @@ package com.interlinedlist.android.feature.directmessages.data import com.interlinedlist.android.feature.directmessages.data.local.ConversationEntity import com.interlinedlist.android.feature.directmessages.data.local.DirectMessageEntity +import com.interlinedlist.android.feature.directmessages.data.remote.dto.ConversationDto import com.interlinedlist.android.feature.directmessages.data.remote.dto.MessageDto import com.interlinedlist.android.feature.directmessages.data.remote.dto.RecipientDto import java.time.Instant @@ -49,13 +50,51 @@ internal fun DirectMessageEntity.toDomain(): DirectMessage = DirectMessage( pending = pending, ) +/** + * Maps a conversation row into its cache entity, or null when the row carries no + * participant username — without one the conversation cannot be keyed or opened. + * + * [currentUserId] is only consulted when the row omits unread bookkeeping and a + * nested last message has to stand in for it. + */ +internal fun ConversationDto.toEntity(currentUserId: String?): ConversationEntity? { + val other = participant ?: return null + val otherUsername = other.username.takeIf { it.isNotBlank() } ?: return null + return ConversationEntity( + username = otherUsername, + pairKey = pairKey, + displayName = other.displayName, + avatarUrl = other.avatar, + lastMessageId = newestMessageId, + lastMessageBody = previewText, + lastMessageAtMillis = parseIsoMillis(lastActivityAt), + unreadCount = resolveUnreadCount(currentUserId), + ) +} + +/** + * The row's unread count, preferring what the server reports and otherwise + * inferring it from the newest message: unread only when the current user is the + * one who received it. + */ +private fun ConversationDto.resolveUnreadCount(currentUserId: String?): Int = when { + unreadCount != null -> unreadCount.coerceAtLeast(0) + hasUnread != null -> if (hasUnread) 1 else 0 + else -> { + val newest = newestMessage + val received = currentUserId != null && newest?.recipientId == currentUserId + if (received && (newest?.readAt ?: readAt) == null) 1 else 0 + } +} + internal fun ConversationEntity.toDomain(): Conversation = Conversation( username = username, displayName = displayName, avatarUrl = avatarUrl, lastMessageBody = lastMessageBody, lastMessageAtMillis = lastMessageAtMillis, - hasUnread = hasUnread, + unreadCount = unreadCount, + pairKey = pairKey, ) internal fun RecipientDto.toDomain(): Recipient = Recipient( diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DmModels.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DmModels.kt index 5089a96..fd9f5dd 100644 --- a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DmModels.kt +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DmModels.kt @@ -1,14 +1,22 @@ package com.interlinedlist.android.feature.directmessages.data -/** A conversation summary shown in the inbox list. */ +/** + * A conversation shown in the inbox — one row per `pairKey`, as returned by + * `GET /api/dm/conversations`. + */ data class Conversation( val username: String, val displayName: String?, val avatarUrl: String?, val lastMessageBody: String, val lastMessageAtMillis: Long, - val hasUnread: Boolean, -) + /** Unread received messages in this conversation, per the server. */ + val unreadCount: Int, + val pairKey: String? = null, +) { + /** Whether the inbox row should render its unread affordance. */ + val hasUnread: Boolean get() = unreadCount > 0 +} /** A single direct message shown in a thread. */ data class DirectMessage( diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/ConversationDao.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/ConversationDao.kt index ad43c1a..d372bfc 100644 --- a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/ConversationDao.kt +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/ConversationDao.kt @@ -20,7 +20,8 @@ interface ConversationDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAll(conversations: List) - @Query("UPDATE dm_conversation SET hasUnread = 0 WHERE username = :username") + /** Zeroes a conversation's unread count (the server does this on thread open). */ + @Query("UPDATE dm_conversation SET unreadCount = 0 WHERE username = :username") suspend fun clearUnread(username: String) @Query("DELETE FROM dm_conversation") diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/ConversationEntity.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/ConversationEntity.kt index bdccf78..6eadb7e 100644 --- a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/ConversationEntity.kt +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/ConversationEntity.kt @@ -4,17 +4,23 @@ import androidx.room.Entity import androidx.room.PrimaryKey /** - * A per-conversation summary backing the inbox list. Keyed by the other - * participant's username so it aligns with the thread endpoints. + * A conversation row from `GET /api/dm/conversations`, cached as the inbox's + * source of truth. Distinct from [DirectMessageEntity]: this is the server's + * per-conversation summary, not a message. + * + * Keyed by the other participant's username because every thread-scoped + * operation in this module (thread fetch, polling, navigation) is addressed by + * username; [pairKey] keeps the server's own grouping anchor alongside it. */ @Entity(tableName = "dm_conversation") data class ConversationEntity( @PrimaryKey val username: String, + val pairKey: String?, val displayName: String?, val avatarUrl: String?, val lastMessageId: String, val lastMessageBody: String, val lastMessageAtMillis: Long, - /** Whether the latest received message in this conversation is unread. */ - val hasUnread: Boolean, + /** Unread received messages in this conversation, as reported by the server. */ + val unreadCount: Int, ) diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/DirectMessagesDatabase.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/DirectMessagesDatabase.kt index 33f050f..f1c6813 100644 --- a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/DirectMessagesDatabase.kt +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/DirectMessagesDatabase.kt @@ -10,7 +10,7 @@ import androidx.room.TypeConverters */ @Database( entities = [DirectMessageEntity::class, ConversationEntity::class], - version = 1, + version = 2, exportSchema = false, ) @TypeConverters(DmConverters::class) diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/DirectMessagesApi.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/DirectMessagesApi.kt index 645710c..ce3f051 100644 --- a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/DirectMessagesApi.kt +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/DirectMessagesApi.kt @@ -1,7 +1,7 @@ package com.interlinedlist.android.feature.directmessages.data.remote +import com.interlinedlist.android.feature.directmessages.data.remote.dto.ConversationsResponse import com.interlinedlist.android.feature.directmessages.data.remote.dto.ImageUploadResponse -import com.interlinedlist.android.feature.directmessages.data.remote.dto.InboxResponse import com.interlinedlist.android.feature.directmessages.data.remote.dto.MessageDto import com.interlinedlist.android.feature.directmessages.data.remote.dto.RecipientsResponse import com.interlinedlist.android.feature.directmessages.data.remote.dto.SendMessageRequest @@ -28,13 +28,15 @@ import retrofit2.http.Query */ interface DirectMessagesApi { - /** Lists a DM folder (default inbox), cursor-paginated by `nextCursor`. */ - @GET("api/dm") - suspend fun getInbox( - @Query("folder") folder: String? = null, + /** + * The inbox: one row per conversation (grouped by `pairKey`), newest + * activity first, cursor-paginated by `nextCursor`. + */ + @GET("api/dm/conversations") + suspend fun getConversations( @Query("cursor") cursor: String? = null, @Query("take") take: Int? = null, - ): InboxResponse + ): ConversationsResponse /** Sends a direct message. */ @POST("api/dm") diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/ConversationDto.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/ConversationDto.kt new file mode 100644 index 0000000..d817349 --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/ConversationDto.kt @@ -0,0 +1,58 @@ +package com.interlinedlist.android.feature.directmessages.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * Wire model for one row of `GET /api/dm/conversations` — a conversation + * grouped by `pairKey`, newest activity first. This is the inbox's data source; + * it is deliberately *not* the message DTO. + * + * The OpenAPI spec declares this response as a bare `object` and the public help + * centre does not document it, so the row is modelled defensively: every field + * is optional, and the keys this API is known to use interchangeably are all + * accepted — the other participant as `otherUser` (as on the thread endpoint), + * `user` or `author` (the documented envelope quirk); the newest message either + * nested under `lastMessage`/`message` or flattened onto the row. Unknown keys + * are ignored by the shared Json configuration. + */ +@Serializable +data class ConversationDto( + /** Stable, sorted `a:b` anchor identifying the conversation server-side. */ + val pairKey: String? = null, + // The other participant, under whichever key the endpoint used. + val otherUser: RecipientDto? = null, + val user: RecipientDto? = null, + val author: RecipientDto? = null, + // The newest message, either nested... + val lastMessage: MessageDto? = null, + val message: MessageDto? = null, + // ...or flattened onto the row itself. + val lastMessageId: String? = null, + val lastMessageBody: String? = null, + val preview: String? = null, + val body: String? = null, + val lastMessageAt: String? = null, + val updatedAt: String? = null, + val createdAt: String? = null, + val readAt: String? = null, + // Unread bookkeeping; `unreadCount` is per conversation, not per folder. + val unreadCount: Int? = null, + val hasUnread: Boolean? = null, +) { + /** The conversation partner under whichever key the endpoint used. */ + val participant: RecipientDto? get() = otherUser ?: user ?: author + + /** The newest message of the conversation when the row nests one. */ + val newestMessage: MessageDto? get() = lastMessage ?: message + + /** Plain-text excerpt to render on the inbox row. */ + val previewText: String + get() = (preview ?: lastMessageBody ?: body ?: newestMessage?.body).orEmpty() + + /** ISO-8601 timestamp of the conversation's newest activity. */ + val lastActivityAt: String? + get() = lastMessageAt ?: newestMessage?.createdAt ?: updatedAt ?: createdAt + + /** Id of the newest message, when the row identifies one. */ + val newestMessageId: String get() = (lastMessageId ?: newestMessage?.id).orEmpty() +} diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/DmResponses.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/DmResponses.kt index 9d29bc5..5cf31d2 100644 --- a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/DmResponses.kt +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/DmResponses.kt @@ -3,17 +3,23 @@ package com.interlinedlist.android.feature.directmessages.data.remote.dto import kotlinx.serialization.Serializable /** - * Response for `GET /api/dm` (inbox/sent folders). + * Response for `GET /api/dm/conversations` — one row per conversation, grouped + * by `pairKey`, newest activity first, cursor-paginated by `nextCursor`. * - * Confirmed live shape: `{ "items": [...], "nextCursor": null }`. Each item is a - * message; the last message of a conversation represents that conversation in - * the inbox folder. + * The response body is unmodelled in the OpenAPI spec, so the list is accepted + * under `items` (the convention every other cursor-paginated endpoint uses) or + * under a named `conversations` key (the convention `recipients`/`notifications` + * use). */ @Serializable -data class InboxResponse( - val items: List = emptyList(), +data class ConversationsResponse( + val items: List = emptyList(), + val conversations: List = emptyList(), val nextCursor: String? = null, -) +) { + /** The conversation rows under whichever key the endpoint used. */ + val rows: List get() = items.ifEmpty { conversations } +} /** * Response for `GET /api/dm/thread/{username}`. @@ -61,8 +67,9 @@ data class SendMessageRequest( ) /** - * Response for `POST /api/dm`. Some create endpoints wrap the created object - * under `data`; accept both the bare message and the wrapped form. + * Response for `POST /api/dm`. The documented shape wraps the created message + * under `message`; other create endpoints wrap it under `data`. Both envelopes + * and the bare message are accepted. */ @Serializable data class SendMessageResponse( @@ -72,10 +79,11 @@ data class SendMessageResponse( val body: String? = null, val imageUrls: List = emptyList(), val createdAt: String? = null, + val message: MessageDto? = null, val data: MessageDto? = null, ) { - /** The created message, unwrapping the `data` envelope when present. */ - fun message(): MessageDto? = data ?: id?.let { + /** The created message, unwrapping the `message`/`data` envelope when present. */ + fun createdMessage(): MessageDto? = message ?: data ?: id?.let { MessageDto( id = it, senderId = senderId ?: "", diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/MessageDto.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/MessageDto.kt index 5118036..e2abac7 100644 --- a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/MessageDto.kt +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/MessageDto.kt @@ -30,7 +30,4 @@ data class MessageDto( val sender: RecipientDto? = null, val author: RecipientDto? = null, val user: RecipientDto? = null, -) { - /** The embedded author under whichever key the endpoint used, if present. */ - val embeddedAuthor: RecipientDto? get() = sender ?: author ?: user -} +) diff --git a/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/data/DefaultDirectMessagesRepositoryTest.kt b/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/data/DefaultDirectMessagesRepositoryTest.kt index bd42abf..6f2043f 100644 --- a/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/data/DefaultDirectMessagesRepositoryTest.kt +++ b/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/data/DefaultDirectMessagesRepositoryTest.kt @@ -65,43 +65,190 @@ class DefaultDirectMessagesRepositoryTest { dispatchers = testDispatchers, ) + private fun conversationsBody( + username: String, + unreadCount: Int, + body: String, + createdAt: String, + nextCursor: String? = null, + ) = """ + { + "items": [ + { + "pairKey": "me:$username", + "otherUser": {"id":"$username-id","username":"$username","displayName":"$username","avatar":null}, + "lastMessage": {"id":"dm-$username","senderId":"$username-id","recipientId":"me", + "body":"$body","createdAt":"$createdAt","readAt":null}, + "unreadCount": $unreadCount + } + ], + "nextCursor": ${if (nextCursor == null) "null" else "\"$nextCursor\""} + } + """.trimIndent() + @Test - fun `refreshInbox caches conversations and returns next cursor`() = runTest { + fun `refreshInbox reads the conversations endpoint and returns the next cursor`() = runTest { + server.enqueue( + MockResponse().setBody( + conversationsBody( + username = "adron", unreadCount = 2, body = "hi there", + createdAt = "2026-07-31T10:00:00Z", nextCursor = "cursor-2", + ), + ), + ) + + val result = repo().refreshInbox(cursor = null) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data).isEqualTo("cursor-2") + assertThat(server.takeRequest().path).startsWith("/api/dm/conversations") + + val conversations = repo().observeConversations().first() + assertThat(conversations).hasSize(1) + with(conversations.first()) { + assertThat(username).isEqualTo("adron") + assertThat(pairKey).isEqualTo("me:adron") + assertThat(lastMessageBody).isEqualTo("hi there") + assertThat(unreadCount).isEqualTo(2) + assertThat(hasUnread).isTrue() + } + } + + @Test + fun `refreshInbox emits one row per conversation`() = runTest { + // Two conversations; the same pairKey never appears twice in a response, + // so the inbox must render exactly one row for each. server.enqueue( MockResponse().setBody( """ { "items": [ - {"id":"m1","senderId":"other","recipientId":"me","body":"hi there", - "createdAt":"2026-07-31T10:00:00Z","readAt":null, - "user":{"id":"other","username":"adron","displayName":"Adron","avatar":null}} + {"pairKey":"me:adron", + "otherUser":{"id":"u2","username":"adron","displayName":"Adron","avatar":null}, + "lastMessage":{"id":"dm_2","senderId":"u2","recipientId":"me","body":"newest", + "createdAt":"2026-07-31T12:00:00Z","readAt":null}, + "unreadCount":1}, + {"pairKey":"me:blake", + "otherUser":{"id":"u3","username":"blake","displayName":"Blake","avatar":null}, + "lastMessage":{"id":"dm_1","senderId":"me","recipientId":"u3","body":"older", + "createdAt":"2026-07-31T09:00:00Z","readAt":null}, + "unreadCount":0} ], - "nextCursor": "cursor-2" + "nextCursor": null } """.trimIndent(), ), ) - val result = repo().refreshInbox(cursor = null) - - assertThat(result).isInstanceOf(ApiResult.Success::class.java) - assertThat((result as ApiResult.Success).data).isEqualTo("cursor-2") + assertThat(repo().refreshInbox()).isInstanceOf(ApiResult.Success::class.java) val conversations = repo().observeConversations().first() - assertThat(conversations).hasSize(1) - assertThat(conversations.first().username).isEqualTo("adron") - assertThat(conversations.first().lastMessageBody).isEqualTo("hi there") - assertThat(conversations.first().hasUnread).isTrue() + // Newest activity first, one row per conversation. + assertThat(conversations.map { it.username }).containsExactly("adron", "blake").inOrder() + assertThat(conversations.first().lastMessageBody).isEqualTo("newest") + assertThat(conversations.last().hasUnread).isFalse() } @Test - fun `refreshInbox forwards the cursor query param`() = runTest { - server.enqueue(MockResponse().setBody("""{"items":[],"nextCursor":null}""")) + fun `cursor paging forwards the cursor and appends the next page`() = runTest { + server.enqueue( + MockResponse().setBody( + conversationsBody( + username = "adron", unreadCount = 1, body = "page one", + createdAt = "2026-07-31T12:00:00Z", nextCursor = "page-2", + ), + ), + ) + server.enqueue( + MockResponse().setBody( + conversationsBody( + username = "blake", unreadCount = 0, body = "page two", + createdAt = "2026-07-31T09:00:00Z", nextCursor = null, + ), + ), + ) - repo().refreshInbox(cursor = "page-2") + val r = repo() + val first = r.refreshInbox(cursor = null) + assertThat((first as ApiResult.Success).data).isEqualTo("page-2") + assertThat(server.takeRequest().path).doesNotContain("cursor=") - val recorded = server.takeRequest() - assertThat(recorded.path).contains("cursor=page-2") + val second = r.refreshInbox(cursor = "page-2") + assertThat((second as ApiResult.Success).data).isNull() + assertThat(server.takeRequest().path).contains("cursor=page-2") + + // Paging appends: page one's conversation is still cached. + val conversations = r.observeConversations().first() + assertThat(conversations.map { it.username }).containsExactly("adron", "blake").inOrder() + } + + @Test + fun `cached unread counts add up to the unread-count endpoint`() = runTest { + server.enqueue( + MockResponse().setBody( + """ + { + "items": [ + {"pairKey":"me:adron", + "otherUser":{"id":"u2","username":"adron"}, + "lastMessage":{"id":"dm_2","senderId":"u2","recipientId":"me","body":"a", + "createdAt":"2026-07-31T12:00:00Z","readAt":null}, + "unreadCount":3}, + {"pairKey":"me:blake", + "otherUser":{"id":"u3","username":"blake"}, + "lastMessage":{"id":"dm_1","senderId":"u3","recipientId":"me","body":"b", + "createdAt":"2026-07-31T11:00:00Z","readAt":null}, + "unreadCount":4} + ], + "nextCursor": null + } + """.trimIndent(), + ), + ) + server.enqueue(MockResponse().setBody("""{"count":7}""")) + + val r = repo() + r.refreshInbox() + val endpointCount = (r.unreadCount() as ApiResult.Success).data + + val summed = r.observeConversations().first().sumOf { it.unreadCount } + assertThat(summed).isEqualTo(endpointCount) + assertThat(r.observeConversations().first().count { it.hasUnread }).isEqualTo(2) + } + + @Test + fun `a conversation without a participant username is skipped`() = runTest { + server.enqueue( + MockResponse().setBody( + """{"items":[{"pairKey":"me:ghost","unreadCount":1}],"nextCursor":null}""", + ), + ) + + assertThat(repo().refreshInbox()).isInstanceOf(ApiResult.Success::class.java) + assertThat(repo().observeConversations().first()).isEmpty() + } + + @Test + fun `opening a thread clears that conversation's unread count`() = runTest { + server.enqueue( + MockResponse().setBody( + conversationsBody( + username = "adron", unreadCount = 2, body = "hi", + createdAt = "2026-07-31T10:00:00Z", + ), + ), + ) + server.enqueue( + MockResponse().setBody("""{"items":[],"olderCursor":null,"isMutual":true,"isBlocked":false}"""), + ) + + val r = repo() + r.refreshInbox() + assertThat(r.observeConversations().first().first().unreadCount).isEqualTo(2) + + r.refreshThread("adron") + + assertThat(r.observeConversations().first().first().unreadCount).isEqualTo(0) } @Test @@ -156,6 +303,28 @@ class DefaultDirectMessagesRepositoryTest { assertThat(thread.first { it.id == "srv1" }.pending).isFalse() } + @Test + fun `send unwraps the documented message envelope`() = runTest { + // POST /api/dm returns `{ "message": { ... } }` (help centre + OpenAPI). + server.enqueue( + MockResponse().setResponseCode(201).setBody( + """ + {"message":{"id":"dm_001","pairKey":"u1:u2","senderId":"me","recipientId":"other", + "body":"hello","imageUrls":[],"createdAt":"2026-07-31T11:00:00Z","readAt":null}} + """.trimIndent(), + ), + ) + + val r = repo() + val result = r.send(username = "adron", body = "hello") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.id).isEqualTo("dm_001") + val thread = r.observeThread("adron").first() + assertThat(thread.map { it.id }).containsExactly("dm_001") + assertThat(thread.first().pending).isFalse() + } + @Test fun `send caches an optimistic message even when the network fails`() = runTest { server.enqueue(MockResponse().setResponseCode(500).setBody("""{"error":"boom"}""")) diff --git a/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/data/FakeDaos.kt b/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/data/FakeDaos.kt index 18fa027..4787f84 100644 --- a/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/data/FakeDaos.kt +++ b/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/data/FakeDaos.kt @@ -71,7 +71,7 @@ class FakeConversationDao : ConversationDao { override suspend fun clearUnread(username: String) { state.value = state.value.map { - if (it.username == username) it.copy(hasUnread = false) else it + if (it.username == username) it.copy(unreadCount = 0) else it } } diff --git a/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/ConversationsResponseTest.kt b/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/ConversationsResponseTest.kt new file mode 100644 index 0000000..3eab8bb --- /dev/null +++ b/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/ConversationsResponseTest.kt @@ -0,0 +1,122 @@ +package com.interlinedlist.android.feature.directmessages.data.remote.dto + +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.json.Json +import org.junit.Test + +/** + * `GET /api/dm/conversations` is not modelled in the OpenAPI spec nor documented + * in the help centre, so these tests pin the tolerant parsing the DTO promises. + */ +class ConversationsResponseTest { + + // Mirrors NetworkModule's configuration. + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true } + + private fun decode(body: String) = + json.decodeFromString(ConversationsResponse.serializer(), body) + + @Test + fun `parses one row per conversation with a nested last message`() { + val response = decode( + """ + { + "items": [ + { + "pairKey": "u1:u2", + "otherUser": {"id":"u2","username":"adron","displayName":"Adron","avatar":"http://a"}, + "lastMessage": { + "id":"dm_010","pairKey":"u1:u2","senderId":"u2","recipientId":"u1", + "body":"see the new grid","createdAt":"2026-09-13T11:00:00.000Z","readAt":null + }, + "unreadCount": 2 + } + ], + "nextCursor": "dm_003" + } + """.trimIndent(), + ) + + assertThat(response.rows).hasSize(1) + assertThat(response.nextCursor).isEqualTo("dm_003") + val row = response.rows.first() + assertThat(row.pairKey).isEqualTo("u1:u2") + assertThat(row.participant?.username).isEqualTo("adron") + assertThat(row.previewText).isEqualTo("see the new grid") + assertThat(row.lastActivityAt).isEqualTo("2026-09-13T11:00:00.000Z") + assertThat(row.newestMessageId).isEqualTo("dm_010") + assertThat(row.unreadCount).isEqualTo(2) + } + + @Test + fun `parses a flattened row that uses preview and lastMessageAt`() { + val response = decode( + """ + { + "conversations": [ + { + "pairKey": "u1:u3", + "user": {"id":"u3","username":"blake"}, + "lastMessageId": "dm_020", + "preview": "short excerpt", + "lastMessageAt": "2026-09-12T08:30:00.000Z", + "hasUnread": true + } + ] + } + """.trimIndent(), + ) + + val row = response.rows.single() + assertThat(row.participant?.username).isEqualTo("blake") + assertThat(row.previewText).isEqualTo("short excerpt") + assertThat(row.lastActivityAt).isEqualTo("2026-09-12T08:30:00.000Z") + assertThat(row.newestMessageId).isEqualTo("dm_020") + assertThat(row.hasUnread).isTrue() + assertThat(response.nextCursor).isNull() + } + + @Test + fun `tolerates explicit nulls, missing fields and unknown keys`() { + val response = decode( + """ + { + "items": [ + { + "pairKey": null, + "otherUser": {"id":"u4","username":"casey","displayName":null,"avatar":null}, + "lastMessage": null, + "unreadCount": null, + "hasUnread": null, + "mutedUntil": "2026-10-01T00:00:00.000Z", + "somethingNobodyModelledYet": {"nested": [1, 2, 3]} + }, + {} + ], + "nextCursor": null, + "totalPages": 4 + } + """.trimIndent(), + ) + + assertThat(response.rows).hasSize(2) + val known = response.rows.first() + assertThat(known.participant?.username).isEqualTo("casey") + assertThat(known.previewText).isEmpty() + assertThat(known.lastActivityAt).isNull() + assertThat(known.unreadCount).isNull() + + // A completely empty row still decodes; the mapper is what drops it. + val empty = response.rows.last() + assertThat(empty.participant).isNull() + assertThat(empty.newestMessageId).isEmpty() + } + + @Test + fun `an empty body decodes to an empty inbox`() { + val response = decode("{}") + + assertThat(response.rows).isEmpty() + assertThat(response.nextCursor).isNull() + } +} diff --git a/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/ui/inbox/InboxViewModelTest.kt b/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/ui/inbox/InboxViewModelTest.kt index 5a44734..882c147 100644 --- a/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/ui/inbox/InboxViewModelTest.kt +++ b/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/ui/inbox/InboxViewModelTest.kt @@ -27,7 +27,8 @@ class InboxViewModelTest { private fun conversation(username: String, unread: Boolean) = Conversation( username = username, displayName = username, avatarUrl = null, - lastMessageBody = "hey", lastMessageAtMillis = 1L, hasUnread = unread, + lastMessageBody = "hey", lastMessageAtMillis = 1L, + unreadCount = if (unread) 1 else 0, ) @Test