feat(social): mark cloud inbox messages read server-side via /v1/social/me/inbox/{id}/read

This commit is contained in:
John Smith
2026-09-13 12:30:14 -04:00
parent 41b51ace8d
commit 2a8db2ff2b
7 changed files with 44 additions and 15 deletions
@@ -9,6 +9,7 @@ data class CloudInboxMessage(
val recipientTag: String,
val subject: String,
val body: String,
val isRead: Boolean,
val createdAt: Long,
)
@@ -89,10 +90,14 @@ class CloudSocialClient(
}
/**
* Remote mark-read. The current dorado-cloud inbox is read-only (there is
* no mark-read route), so this always reports false; the Room store owns
* read state. Kept on the client so a future server route can be wired
* without changing callers.
* Marks a message read server-side (`POST /v1/social/me/inbox/{id}/read`).
* Room remains the source of truth for the UI; this keeps the server's read
* state in step and reports false when unavailable. Best-effort by callers.
*/
suspend fun markRead(id: String): Boolean = false
suspend fun markRead(id: String): Boolean {
if (id.isBlank()) return false
val auth = token() ?: return false
val client = clientOrNull() ?: return false
return client.markInboxRead(id, auth)
}
}
@@ -341,11 +341,16 @@ class DoradoCloudClient(private val http: CloudHttp) {
recipientTag = CloudJson.stringOr(obj, "recipientTag", ""),
subject = CloudJson.stringOr(obj, "subject", ""),
body = CloudJson.stringOr(obj, "body", ""),
isRead = CloudJson.bool(obj, "isRead"),
createdAt = parseTimestamp(CloudJson.stringOr(obj, "createdAt", "")),
)
}
}
/** Marks one of the signed-in user's inbox messages read; false when not found. */
suspend fun markInboxRead(id: String, token: String? = null): Boolean =
action("v1/social/me/inbox/${enc(id)}/read", "POST", token)
suspend fun follow(handle: String, token: String? = null): Boolean = action("v1/social/profiles/${enc(handle)}/follow", "POST", token)
suspend fun unfollow(handle: String, token: String? = null): Boolean = action("v1/social/profiles/${enc(handle)}/follow", "DELETE", token)
@@ -66,7 +66,7 @@ class InboxRepository(
recipientTag = message.recipientTag.ifBlank { inbox.zuneTag },
subject = message.subject,
body = message.body,
isRead = existing[message.id]?.isRead ?: false,
isRead = (existing[message.id]?.isRead ?: false) || message.isRead,
createdAt = message.createdAt,
)
}
@@ -70,6 +70,7 @@ class CloudSocialClientTest {
assertEquals("mira", message.senderTag)
assertEquals("hello", message.subject)
assertEquals("hi there", message.body)
assertFalse(message.isRead)
assertTrue(message.createdAt > 0)
}
@@ -83,12 +84,16 @@ class CloudSocialClientTest {
}
@Test
fun `mark read reports the remote cannot accept it`() = runBlocking {
fun `mark read posts to the modern route`() = runBlocking {
val http = FakeCloudHttp()
http.enqueue(204, "")
val client = CloudSocialClient({ settings(true) }, { http })
assertFalse(client.markRead("11111111-1111-1111-1111-111111111111"))
assertNull(http.lastPath)
assertTrue(client.markRead("11111111-1111-1111-1111-111111111111"))
assertEquals("v1/social/me/inbox/11111111-1111-1111-1111-111111111111/read", http.lastPath)
assertEquals("POST", http.lastMethod)
assertEquals("Bearer tok", http.lastAuth)
}
@Test
@@ -70,6 +70,18 @@ class InboxRepositoryTest {
assertEquals(1, repo.unreadCount().first())
}
@Test
fun `server read state merges into the cache`() = runBlocking {
val http = FakeCloudHttp()
enqueueInbox(http, INBOX_READ)
val repo = repository(http, enabled = true)
repo.sync()
assertTrue(repo.messages().first().single().isRead)
assertEquals(0, repo.unreadCount().first())
}
@Test
fun `sync preserves local read state across a refresh`() = runBlocking {
val http = FakeCloudHttp()
@@ -121,6 +133,9 @@ class InboxRepositoryTest {
private companion object {
val INBOX_ONE =
"""[{"id":"11111111-1111-1111-1111-111111111111","senderTag":"mira","recipientTag":"jane","subject":"hello","body":"hi there","createdAt":"2026-01-02T03:04:05+00:00"}]"""
"""[{"id":"11111111-1111-1111-1111-111111111111","senderTag":"mira","recipientTag":"jane","subject":"hello","body":"hi there","isRead":false,"createdAt":"2026-01-02T03:04:05+00:00"}]"""
val INBOX_READ =
"""[{"id":"11111111-1111-1111-1111-111111111111","senderTag":"mira","recipientTag":"jane","subject":"hello","body":"hi there","isRead":true,"createdAt":"2026-01-02T03:04:05+00:00"}]"""
}
}
+3 -3
View File
@@ -265,9 +265,9 @@ explicitly documented (long-term / N-A) — no gap unplanned.
with cloud enhancement when signed in (`CloudSocialClient` feed/card),
local-first `inbox_messages` Room table (v8) + `InboxRepository` with
mark-read and offline labels. The cloud reads the same store through the
modern bearer route `GET /v1/social/me/inbox` (JSON), so no legacy gateway
host proxying is needed; the inbox is still read-only server-side, so read
state stays local-only.
modern bearer route `GET /v1/social/me/inbox` (JSON) and marks read through
`POST /v1/social/me/inbox/{id}/read`, so no legacy gateway host proxying is
needed. Room remains the source of truth; server read state merges in.
- ✅ **D4** Audiobooks: `AudiobookGrouping` (genre/directory heuristics) →
Room v7 `audiobooks`/`audiobook_parts` + `AudiobookRepository` → music
crossbar `audiobooks` pivot (book list, parts, resume/bookmark, 0.75–2.0×
+1 -2
View File
@@ -170,7 +170,6 @@ launch (63/63 crawl failures); fixed by resuming controller calls on
service connection — the on-device gate is the reason this shipped correctly.
**Documented N/A / deferred:** live-radio ±5-min rewind (needs a time-shifting
server); cloud inbox mark-read (server serves it read-only; read state is
local); the four remaining emulator titles (two corpus gaps, two dead
server); the four remaining emulator titles (two corpus gaps, two dead
services); desktop i18n remaining locales (deferred registry, 20 shipped);
organization repository pinning (profile-level manual step).