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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ class GuildRepository @Inject constructor(
private val _channels = MutableStateFlow<List<Channel>>(emptyList())
val channels: StateFlow<List<Channel>> = _channels.asStateFlow()

/** The channel the user currently has open; its messages count as read. */
private val _activeChannelId = MutableStateFlow<String?>(null)

suspend fun loadGuilds() {
val guildList = guildApi.getGuilds()
_guilds.value = guildList
Expand All @@ -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<Channel>, channelId: String): List<Channel> =
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<Channel>, channelId: String): List<Channel> =
channels.map { if (it.id == channelId) it.copy(unreadCount = 0) else it }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading