Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -43,11 +41,12 @@ class DefaultDirectMessagesRepository @Inject constructor(

override suspend fun refreshInbox(cursor: String?): ApiResult<String?> =
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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -181,26 +180,4 @@ class DefaultDirectMessagesRepository @Inject constructor(
override suspend fun unreadCount(): ApiResult<Int> = 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,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<Conversation>>

/** Oldest-first stream of a single conversation's cached messages. */
fun observeThread(username: String): Flow<List<DirectMessage>>

/**
* 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<String?>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ interface ConversationDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(conversations: List<ConversationEntity>)

@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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import androidx.room.TypeConverters
*/
@Database(
entities = [DirectMessageEntity::class, ConversationEntity::class],
version = 1,
version = 2,
exportSchema = false,
)
@TypeConverters(DmConverters::class)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<MessageDto> = emptyList(),
data class ConversationsResponse(
val items: List<ConversationDto> = emptyList(),
val conversations: List<ConversationDto> = emptyList(),
val nextCursor: String? = null,
)
) {
/** The conversation rows under whichever key the endpoint used. */
val rows: List<ConversationDto> get() = items.ifEmpty { conversations }
}

/**
* Response for `GET /api/dm/thread/{username}`.
Expand Down Expand Up @@ -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(
Expand All @@ -72,10 +79,11 @@ data class SendMessageResponse(
val body: String? = null,
val imageUrls: List<String> = 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 ?: "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
)
Loading
Loading