feat: add support for unread message and room highlighting #49

Merged
reya merged 2 commits from feat/new-message-ui into master 2026-07-21 03:44:54 +00:00
4 changed files with 86 additions and 26 deletions

View File

@@ -25,6 +25,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -655,27 +657,39 @@ fun NewRequests(requests: List<Room>) {
else -> "" else -> ""
} }
val totalUnread = requests.sumOf { it.unreadCount }
ListItem( ListItem(
modifier = Modifier.clickable { modifier = Modifier.clickable {
navigator.navigate(Screen.RequestList) navigator.navigate(Screen.RequestList)
}, },
leadingContent = { leadingContent = {
Box( BadgedBox(
modifier = Modifier badge = {
.size(48.dp) if (totalUnread > 0) {
.clip(MaterialShapes.Clover4Leaf.toShape()), Badge {
contentAlignment = Alignment.Center Text(totalUnread.toString())
}
}
}
) { ) {
Surface( Box(
modifier = Modifier.size(48.dp), modifier = Modifier
color = MaterialTheme.colorScheme.tertiaryContainer, .size(48.dp)
.clip(MaterialShapes.Clover4Leaf.toShape()),
contentAlignment = Alignment.Center
) { ) {
Box(contentAlignment = Alignment.Center) { Surface(
Icon( modifier = Modifier.size(48.dp),
painter = painterResource(Res.drawable.ic_request), color = MaterialTheme.colorScheme.tertiaryContainer,
contentDescription = "Requests", ) {
tint = MaterialTheme.colorScheme.onTertiaryFixed Box(contentAlignment = Alignment.Center) {
) Icon(
painter = painterResource(Res.drawable.ic_request),
contentDescription = "Requests",
tint = MaterialTheme.colorScheme.onTertiaryFixed
)
}
} }
} }
} }
@@ -683,14 +697,19 @@ fun NewRequests(requests: List<Room>) {
headlineContent = { headlineContent = {
Text( Text(
text = "Requests", text = "Requests",
style = MaterialTheme.typography.titleMediumEmphasized style = MaterialTheme.typography.titleMediumEmphasized.copy(
fontWeight = if (totalUnread > 0) FontWeight.SemiBold else FontWeight.Normal
)
) )
}, },
supportingContent = { supportingContent = {
if (supportingText.isNotEmpty()) { if (supportingText.isNotEmpty()) {
Text( Text(
text = supportingText, text = supportingText,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = if (totalUnread > 0) FontWeight.SemiBold else FontWeight.Normal,
color = if (totalUnread > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline
),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@@ -712,22 +731,35 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
ListItem( ListItem(
modifier = Modifier.clickable(onClick = onClick), modifier = Modifier.clickable(onClick = onClick),
leadingContent = { leadingContent = {
Avatar(picture = roomState.picture, description = roomState.picture) BadgedBox(
badge = {
if (room.unreadCount > 0) {
Badge {
Text(room.unreadCount.toString())
}
}
}
) {
Avatar(picture = roomState.picture, description = roomState.picture)
}
}, },
headlineContent = { headlineContent = {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = roomState.name, text = roomState.name,
style = MaterialTheme.typography.titleMediumEmphasized, style = MaterialTheme.typography.titleMediumEmphasized.copy(
fontWeight = if (room.unreadCount > 0) FontWeight.SemiBold else FontWeight.Normal
),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
Text( Text(
text = room.createdAt.ago(), text = room.createdAt.ago(),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline color = MaterialTheme.colorScheme.outline,
textAlign = TextAlign.End,
) )
} }
}, },
@@ -735,7 +767,10 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
if (!room.lastMessage.isNullOrBlank()) { if (!room.lastMessage.isNullOrBlank()) {
Text( Text(
text = room.lastMessage ?: "", text = room.lastMessage ?: "",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = if (room.unreadCount > 0) FontWeight.SemiBold else FontWeight.Normal,
color = if (room.unreadCount > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline
),
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
maxLines = 1, maxLines = 1,
) )

View File

@@ -30,7 +30,8 @@ data class Room(
val subject: String?, val subject: String?,
val members: Set<PublicKey>, val members: Set<PublicKey>,
val kind: RoomKind = RoomKind.default(), val kind: RoomKind = RoomKind.default(),
val lastMessage: String? = null val lastMessage: String? = null,
val unreadCount: Int = 0
) : Comparable<Room> { ) : Comparable<Room> {
override fun compareTo(other: Room): Int { override fun compareTo(other: Room): Int {
return this.createdAt.asSecs().compareTo(other.createdAt.asSecs()) return this.createdAt.asSecs().compareTo(other.createdAt.asSecs())

View File

@@ -130,6 +130,19 @@ class ChatRepository(
return _state.value.rooms[id] return _state.value.rooms[id]
} }
fun markAsRead(roomId: Long) {
_state.update { currentState ->
val rooms = currentState.rooms.toMutableMap()
val room = rooms[roomId]
if (room != null && room.unreadCount > 0) {
rooms[roomId] = room.copy(unreadCount = 0)
currentState.copy(rooms = rooms)
} else {
currentState
}
}
}
fun refreshChatRooms() { fun refreshChatRooms() {
scope.launch(defaultDispatcher) { scope.launch(defaultDispatcher) {
try { try {
@@ -140,10 +153,14 @@ class ChatRepository(
val existing = newMap[dbRoom.id] val existing = newMap[dbRoom.id]
// Only update if the database version is newer or equal // Only update if the database version is newer or equal
if (existing == null || dbRoom.createdAt.asSecs() >= existing.createdAt.asSecs()) { if (existing == null || dbRoom.createdAt.asSecs() >= existing.createdAt.asSecs()) {
// Preserve Ongoing kind if already marked as such in memory // Preserve Ongoing kind and unreadCount status if already marked as such in memory
val mergedKind = val mergedKind =
if (existing?.kind == RoomKind.Ongoing) RoomKind.Ongoing else dbRoom.kind if (existing?.kind == RoomKind.Ongoing) RoomKind.Ongoing else dbRoom.kind
newMap[dbRoom.id] = dbRoom.copy(kind = mergedKind) val mergedUnreadCount = existing?.unreadCount ?: 0
newMap[dbRoom.id] = dbRoom.copy(
kind = mergedKind,
unreadCount = mergedUnreadCount
)
} }
} }
currentState.copy(rooms = newMap) currentState.copy(rooms = newMap)
@@ -237,14 +254,18 @@ class ChatRepository(
if (existingRoom == null) { if (existingRoom == null) {
// New room discovery // New room discovery
val newRoom = Room.new(event, currentUser, roomId).copy(kind = newKind) val newRoom = Room.new(event, currentUser, roomId).copy(
kind = newKind,
unreadCount = if (isFromMe) 0 else 1
)
rooms[newRoom.id] = newRoom rooms[newRoom.id] = newRoom
} else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) { } else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
// Only update preview if message is newer (handles sync/late arrivals) // Only update preview if message is newer (handles sync/late arrivals)
rooms[roomId] = existingRoom.copy( rooms[roomId] = existingRoom.copy(
lastMessage = event.content(), lastMessage = event.content(),
createdAt = event.createdAt(), createdAt = event.createdAt(),
kind = newKind kind = newKind,
unreadCount = if (isFromMe) existingRoom.unreadCount else existingRoom.unreadCount + 1
) )
} else if (isFromMe && existingRoom.kind != RoomKind.Ongoing) { } else if (isFromMe && existingRoom.kind != RoomKind.Ongoing) {
// Even if it's an older message, if it's from me, the room is ongoing // Even if it's an older message, if it's from me, the room is ongoing

View File

@@ -42,6 +42,8 @@ class ChatScreenViewModel(
messages.clear() messages.clear()
messages.addAll(initialMessages.distinctBy { it.id() }) messages.addAll(initialMessages.distinctBy { it.id() })
loading = false loading = false
// Mark the room as read once messages are loaded
chatRepository.markAsRead(id)
} }
} }
@@ -55,6 +57,7 @@ class ChatScreenViewModel(
if (event.roomId() == id) { if (event.roomId() == id) {
if (messages.none { it.id() == event.id() }) { if (messages.none { it.id() == event.id() }) {
messages.add(0, event) messages.add(0, event)
chatRepository.markAsRead(id)
} }
} else { } else {
newOtherMessages++ newOtherMessages++