From 1aadce9966a75e625be4fdf05d46f44ad7046a6f Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 13 Jun 2026 01:55:52 +0200 Subject: [PATCH] feat(android): unread indicators in the channel list (beta blocker 3/5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third Android-beta blocker. The server's guild-channels endpoint already returns unread_count / last_message_id / last_read_message_id, but the Android Channel model dropped them and nothing rendered unread state. - Channel model: parse unreadCount / lastMessageId / lastReadMessageId (defaulted so channel responses that omit them still deserialize under the SnakeCase naming strategy). - GuildRepository: tracks the active (open) channel; onMessageReceived() bumps a channel's unread badge unless it's the one being read, setActiveChannel() clears it on open. Increment/clear extracted to pure companion helpers with unit tests. - ChatRepository.handleMessageNew calls guildRepository.onMessageReceived (GuildRepository injected — no cycle). - HomeViewModel.onChannelSelected marks a text channel active/read on open. - ChannelList: unread channels get a count Badge (99+ cap) and a bolder, brighter name. 4 JUnit tests for the pure unread helpers. CI Android Build + testDebugUnitTest validate (no local Kotlin build). 2 blockers remain (DM UI, reaction picker). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + .../kaiku/data/repository/ChatRepository.kt | 3 ++ .../kaiku/data/repository/GuildRepository.kt | 35 +++++++++++++++ .../io/wolftown/kaiku/domain/model/Channel.kt | 7 ++- .../io/wolftown/kaiku/ui/home/ChannelList.kt | 16 ++++++- .../wolftown/kaiku/ui/home/HomeViewModel.kt | 4 ++ .../kaiku/domain/UnreadTrackingTest.kt | 45 +++++++++++++++++++ 7 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 mobile/android/app/src/test/java/io/wolftown/kaiku/domain/UnreadTrackingTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 35e21f8e..af6d6c82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - 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 - Desktop auto-update: the app checks GitHub Releases for new signed builds shortly after startup, downloads updates in the background, and offers a one-click restart to apply — release artifacts are now cryptographically signed and ship with an updater manifest - Desktop system tray: Kaiku now lives in the tray with a Show/Quit menu, closing the window hides it to the tray instead of quitting (use the tray's "Quit Kaiku" to exit), and the tray shows your total unread count (tooltip everywhere, number badge on macOS and most Linux trays) diff --git a/mobile/android/app/src/main/java/io/wolftown/kaiku/data/repository/ChatRepository.kt b/mobile/android/app/src/main/java/io/wolftown/kaiku/data/repository/ChatRepository.kt index cae54796..8a7f9aff 100644 --- a/mobile/android/app/src/main/java/io/wolftown/kaiku/data/repository/ChatRepository.kt +++ b/mobile/android/app/src/main/java/io/wolftown/kaiku/data/repository/ChatRepository.kt @@ -36,6 +36,7 @@ class ChatRepository @Inject constructor( private val messageApi: MessageApi, private val webSocket: KaikuWebSocket, private val json: Json, + private val guildRepository: GuildRepository, @ChatCoroutineScope private val scope: CoroutineScope, ) { companion object { @@ -181,6 +182,8 @@ class ChatRepository @Inject constructor( // Avoid duplicates (e.g., from optimistic send) if (current.none { it.id == message.id }) { flow.value = current + message + // Raise the unread badge for non-active channels. + guildRepository.onMessageReceived(event.channelId) } } catch (e: Exception) { logger.log(Level.WARNING, "Failed to deserialize MessageNew payload", e) diff --git a/mobile/android/app/src/main/java/io/wolftown/kaiku/data/repository/GuildRepository.kt b/mobile/android/app/src/main/java/io/wolftown/kaiku/data/repository/GuildRepository.kt index c876ed99..ab3b17dc 100644 --- a/mobile/android/app/src/main/java/io/wolftown/kaiku/data/repository/GuildRepository.kt +++ b/mobile/android/app/src/main/java/io/wolftown/kaiku/data/repository/GuildRepository.kt @@ -24,6 +24,9 @@ class GuildRepository @Inject constructor( private val _channels = MutableStateFlow>(emptyList()) val channels: StateFlow> = _channels.asStateFlow() + /** The channel the user currently has open; its messages count as read. */ + private val _activeChannelId = MutableStateFlow(null) + suspend fun loadGuilds() { val guildList = guildApi.getGuilds() _guilds.value = guildList @@ -37,4 +40,36 @@ class GuildRepository @Inject constructor( val channelList = channelApi.getChannels(guildId) _channels.value = channelList } + + /** + * Mark a channel as the active (open) one: clears its unread count and + * records it so incoming messages there don't re-raise the badge. + */ + fun setActiveChannel(channelId: String?) { + _activeChannelId.value = channelId + if (channelId != null) { + _channels.value = clearUnread(_channels.value, channelId) + } + } + + /** + * Called when a new message arrives (from the WS handler). Bumps the + * channel's unread badge unless it's the channel the user is reading. + */ + fun onMessageReceived(channelId: String) { + if (channelId == _activeChannelId.value) return + _channels.value = incrementUnread(_channels.value, channelId) + } + + companion object { + /** Increment unread for one channel in the list (pure, testable). */ + fun incrementUnread(channels: List, channelId: String): List = + channels.map { + if (it.id == channelId) it.copy(unreadCount = it.unreadCount + 1) else it + } + + /** Reset unread for one channel in the list (pure, testable). */ + fun clearUnread(channels: List, channelId: String): List = + channels.map { if (it.id == channelId) it.copy(unreadCount = 0) else it } + } } diff --git a/mobile/android/app/src/main/java/io/wolftown/kaiku/domain/model/Channel.kt b/mobile/android/app/src/main/java/io/wolftown/kaiku/domain/model/Channel.kt index bda92013..366696df 100644 --- a/mobile/android/app/src/main/java/io/wolftown/kaiku/domain/model/Channel.kt +++ b/mobile/android/app/src/main/java/io/wolftown/kaiku/domain/model/Channel.kt @@ -19,5 +19,10 @@ data class Channel( val topic: String? = null, val userLimit: Int? = null, val position: Int = 0, - val createdAt: String = "" + val createdAt: String = "", + // Unread tracking (server sends these on the guild channels endpoint; + // defaulted so other channel responses that omit them still parse). + val unreadCount: Int = 0, + val lastMessageId: String? = null, + val lastReadMessageId: String? = null, ) diff --git a/mobile/android/app/src/main/java/io/wolftown/kaiku/ui/home/ChannelList.kt b/mobile/android/app/src/main/java/io/wolftown/kaiku/ui/home/ChannelList.kt index 16838b36..e9240f44 100644 --- a/mobile/android/app/src/main/java/io/wolftown/kaiku/ui/home/ChannelList.kt +++ b/mobile/android/app/src/main/java/io/wolftown/kaiku/ui/home/ChannelList.kt @@ -4,6 +4,7 @@ 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.material3.Badge import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -102,13 +103,26 @@ private fun ChannelItem( color = MaterialTheme.colorScheme.onSurfaceVariant ) + val hasUnread = channel.unreadCount > 0 Text( text = channel.name, style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, + // Unread channels read brighter and bolder, matching the desktop. + fontWeight = if (hasUnread) FontWeight.SemiBold else FontWeight.Normal, + color = if (hasUnread) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, modifier = Modifier.weight(1f) ) + if (hasUnread) { + Badge { + Text(text = if (channel.unreadCount > 99) "99+" else channel.unreadCount.toString()) + } + } + if (channel.channelType == ChannelType.VOICE && channel.userLimit != null && channel.userLimit > 0) { Text( text = "/${channel.userLimit}", diff --git a/mobile/android/app/src/main/java/io/wolftown/kaiku/ui/home/HomeViewModel.kt b/mobile/android/app/src/main/java/io/wolftown/kaiku/ui/home/HomeViewModel.kt index 6a102cc5..156c3a96 100644 --- a/mobile/android/app/src/main/java/io/wolftown/kaiku/ui/home/HomeViewModel.kt +++ b/mobile/android/app/src/main/java/io/wolftown/kaiku/ui/home/HomeViewModel.kt @@ -73,6 +73,10 @@ class HomeViewModel @Inject constructor( } fun onChannelSelected(channelId: String, channelType: ChannelType) { + // Opening a text channel marks it read and clears its unread badge. + if (channelType == ChannelType.TEXT) { + guildRepository.setActiveChannel(channelId) + } val result = _navigateToChannel.trySend(ChannelNavEvent(channelId, channelType)) if (result.isFailure) { logger.warning("navigateToChannel dropped (collector suspended or buffer full): $channelId") diff --git a/mobile/android/app/src/test/java/io/wolftown/kaiku/domain/UnreadTrackingTest.kt b/mobile/android/app/src/test/java/io/wolftown/kaiku/domain/UnreadTrackingTest.kt new file mode 100644 index 00000000..1fbbd701 --- /dev/null +++ b/mobile/android/app/src/test/java/io/wolftown/kaiku/domain/UnreadTrackingTest.kt @@ -0,0 +1,45 @@ +package io.wolftown.kaiku.domain + +import io.wolftown.kaiku.data.repository.GuildRepository +import io.wolftown.kaiku.domain.model.Channel +import io.wolftown.kaiku.domain.model.ChannelType +import org.junit.Assert.assertEquals +import org.junit.Test + +class UnreadTrackingTest { + + private fun channel(id: String, unread: Int = 0) = Channel( + id = id, + name = "c$id", + channelType = ChannelType.TEXT, + unreadCount = unread, + ) + + @Test + fun incrementUnread_bumpsOnlyTheTargetChannel() { + val before = listOf(channel("a", 0), channel("b", 2)) + val after = GuildRepository.incrementUnread(before, "b") + assertEquals(0, after.first { it.id == "a" }.unreadCount) + assertEquals(3, after.first { it.id == "b" }.unreadCount) + } + + @Test + fun incrementUnread_unknownChannel_isNoOp() { + val before = listOf(channel("a", 1)) + assertEquals(before, GuildRepository.incrementUnread(before, "missing")) + } + + @Test + fun clearUnread_resetsOnlyTheTargetChannel() { + val before = listOf(channel("a", 5), channel("b", 3)) + val after = GuildRepository.clearUnread(before, "a") + assertEquals(0, after.first { it.id == "a" }.unreadCount) + assertEquals(3, after.first { it.id == "b" }.unreadCount) + } + + @Test + fun channel_defaultsUnreadToZero() { + // Channel responses that omit unread fields must still parse/construct. + assertEquals(0, channel("a").unreadCount) + } +}