diff --git a/app/src/main/java/com/heretek/dorado_hd/cloud/CloudSocialClient.kt b/app/src/main/java/com/heretek/dorado_hd/cloud/CloudSocialClient.kt index 1805ec2..bc9317c 100644 --- a/app/src/main/java/com/heretek/dorado_hd/cloud/CloudSocialClient.kt +++ b/app/src/main/java/com/heretek/dorado_hd/cloud/CloudSocialClient.kt @@ -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) + } } diff --git a/app/src/main/java/com/heretek/dorado_hd/cloud/DoradoCloudClient.kt b/app/src/main/java/com/heretek/dorado_hd/cloud/DoradoCloudClient.kt index cdc18ea..57fd608 100644 --- a/app/src/main/java/com/heretek/dorado_hd/cloud/DoradoCloudClient.kt +++ b/app/src/main/java/com/heretek/dorado_hd/cloud/DoradoCloudClient.kt @@ -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) diff --git a/app/src/main/java/com/heretek/dorado_hd/data/repo/InboxRepository.kt b/app/src/main/java/com/heretek/dorado_hd/data/repo/InboxRepository.kt index 7af3bf7..2ca77a2 100644 --- a/app/src/main/java/com/heretek/dorado_hd/data/repo/InboxRepository.kt +++ b/app/src/main/java/com/heretek/dorado_hd/data/repo/InboxRepository.kt @@ -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, ) } diff --git a/app/src/test/java/com/heretek/dorado_hd/CloudSocialClientTest.kt b/app/src/test/java/com/heretek/dorado_hd/CloudSocialClientTest.kt index 62a82ed..bb977ed 100644 --- a/app/src/test/java/com/heretek/dorado_hd/CloudSocialClientTest.kt +++ b/app/src/test/java/com/heretek/dorado_hd/CloudSocialClientTest.kt @@ -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 diff --git a/app/src/test/java/com/heretek/dorado_hd/InboxRepositoryTest.kt b/app/src/test/java/com/heretek/dorado_hd/InboxRepositoryTest.kt index 360e9db..f03e5bd 100644 --- a/app/src/test/java/com/heretek/dorado_hd/InboxRepositoryTest.kt +++ b/app/src/test/java/com/heretek/dorado_hd/InboxRepositoryTest.kt @@ -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"}]""" } } diff --git a/docs/parity-roadmap.md b/docs/parity-roadmap.md index f522bb7..7c17cdc 100644 --- a/docs/parity-roadmap.md +++ b/docs/parity-roadmap.md @@ -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× diff --git a/docs/ui-parity-audit.md b/docs/ui-parity-audit.md index 7af05e1..af651f5 100644 --- a/docs/ui-parity-audit.md +++ b/docs/ui-parity-audit.md @@ -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).