From 66108d6c5d3a7efa135a898f9c59938c753801bc Mon Sep 17 00:00:00 2001 From: kerdx <69465056+kerdx@users.noreply.github.com> Date: Thu, 17 Sep 2026 09:44:32 +0200 Subject: [PATCH] feat: persistent in-list day separator and motd-aligned bubble grouping --- .../pm/antani/resentin/ui/chat/ChatScreen.kt | 40 +++++++++++--- .../antani/resentin/ui/chat/ChatTimeline.kt | 33 ++++++++++++ .../resentin/ui/chat/ChatTimelineTest.kt | 53 +++++++++++++++++++ 3 files changed, 118 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/pm/antani/resentin/ui/chat/ChatScreen.kt b/app/src/main/java/pm/antani/resentin/ui/chat/ChatScreen.kt index aa3e162..9f2e31f 100644 --- a/app/src/main/java/pm/antani/resentin/ui/chat/ChatScreen.kt +++ b/app/src/main/java/pm/antani/resentin/ui/chat/ChatScreen.kt @@ -1213,13 +1213,13 @@ fun ChatScreen( is ChatTimelineRow.Message -> item(key = row.key) { val message = row.message val previous = timelineRows.getOrNull(index - 1)?.messages?.lastOrNull() - val gapFromPrevious = previous?.let { message.serverTime - it.serverTime } - val tight = previous != null && - index != dividerIndex && - gapFromPrevious != null && gapFromPrevious in 0..MESSAGE_GROUP_WINDOW_MS && - previous.kind !in SYSTEM_EVENT_KINDS && - message.kind !in SYSTEM_EVENT_KINDS && - previous.sender.equals(message.sender, ignoreCase = true) + // Persistent in-list day chip (motd parity): drawn inside this + // row's own item, so item indexes, keys and jump math never shift. + val showDay = previous == null || !isSameDay(previous.serverTime, message.serverTime) + val tight = index != dividerIndex && + continuesMessageGroup(previous, message) + Column(modifier = Modifier.fillMaxWidth()) { + if (showDay) DaySeparatorRow(timeMillis = message.serverTime, density = messageDensity) ChatTimelineMessageItem( message = message, members = members, @@ -1237,6 +1237,7 @@ fun ChatScreen( selectingMessageId = selectingMessageId, tight = tight, ) + } } is ChatTimelineRow.PresenceSummary -> item(key = row.key) { val burstKey = row.messages.first().id @@ -1248,6 +1249,14 @@ fun ChatScreen( uniqueUsers, uniqueUsers, ) + // Same day chip as message rows: the burst is one visual unit, + // so the chip goes above it, never between its events. + val burstPrevious = timelineRows.getOrNull(index - 1)?.messages?.lastOrNull() + val burstFirst = row.messages.first() + val showBurstDay = burstPrevious == null || + !isSameDay(burstPrevious.serverTime, burstFirst.serverTime) + Column(modifier = Modifier.fillMaxWidth()) { + if (showBurstDay) DaySeparatorRow(timeMillis = burstFirst.serverTime, density = messageDensity) Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { PresenceBurstSummaryRow( sender = senderLabel, @@ -1287,6 +1296,7 @@ fun ChatScreen( } } } + } } } } @@ -2548,6 +2558,20 @@ private fun DateChip(timeMillis: Long, modifier: Modifier = Modifier) { } } +/** Persistent in-list day chip (motd parity): same look as the floating + * DateChip shown while scrolling, but always visible. Drawn inside the + * opening row's own LazyColumn item (never a separate item), so item + * indexes, keys and jump math never shift. Works in both display modes. */ +@Composable +private fun DaySeparatorRow(timeMillis: Long, density: MessageDensity) { + Box( + modifier = Modifier.fillMaxWidth().padding(vertical = density.dividerVertical()), + contentAlignment = Alignment.Center, + ) { + DateChip(timeMillis = timeMillis) + } +} + @Composable private fun UnreadDivider(density: MessageDensity) { Row( @@ -2927,7 +2951,7 @@ private fun buildNickLine( append(withClickableLinks(mircAnnotatedString(body, lightTheme, stripFormatting), linkStylesFor(lightTheme), onDccFileClick, onChannelClick)) } -private const val MESSAGE_GROUP_WINDOW_MS = 5 * 60 * 1000L +// MESSAGE_GROUP_WINDOW_MS lives in ChatTimeline.kt next to continuesMessageGroup. private val SYSTEM_EVENT_KINDS = setOf("join", "part", "quit", "kick", "mode", "nick_change", "topic") private val PRESENCE_EVENT_KINDS = setOf("join", "part", "quit") private enum class ActivityFilter { ALL, PRESENCE, OTHER } diff --git a/app/src/main/java/pm/antani/resentin/ui/chat/ChatTimeline.kt b/app/src/main/java/pm/antani/resentin/ui/chat/ChatTimeline.kt index 3c26519..98edcb2 100644 --- a/app/src/main/java/pm/antani/resentin/ui/chat/ChatTimeline.kt +++ b/app/src/main/java/pm/antani/resentin/ui/chat/ChatTimeline.kt @@ -1,6 +1,8 @@ package pm.antani.resentin.ui.chat import pm.antani.resentin.data.db.MessageEntity +import java.time.Instant +import java.time.ZoneId import java.util.Locale sealed interface ChatTimelineRow { @@ -154,3 +156,34 @@ private const val MIN_SUPPRESSED_PRESENCE_SUMMARY_SIZE = 2 private const val SMART_PRESENCE_ACTIVE_WINDOW_MS = 10 * 60_000L private const val PRESENCE_BURST_MAX_GAP_MS = 30_000L private const val PRESENCE_BURST_MAX_SPAN_MS = 120_000L + +/** Bubble-grouping window: same-sender messages within it render tight. */ +const val MESSAGE_GROUP_WINDOW_MS = 5 * 60 * 1000L + +/** True when both timestamps fall on the same calendar day in [zone]. */ +fun isSameDay(aMs: Long, bMs: Long, zone: ZoneId = ZoneId.systemDefault()): Boolean { + val a = Instant.ofEpochMilli(aMs).atZone(zone).toLocalDate() + val b = Instant.ofEpochMilli(bMs).atZone(zone).toLocalDate() + return a == b +} + +/** + * Whether [message] continues [previous]'s bubble group (motd's `showsSender` + * parity): same sender, within the group window, and no group-breaking + * boundary in between — a system/chat kind change, an ACTION (`/me`) on + * either side, or a day change. The unread divider is a UI concern and stays + * at the call site. + */ +fun continuesMessageGroup( + previous: MessageEntity?, + message: MessageEntity, + gapWindowMs: Long = MESSAGE_GROUP_WINDOW_MS, +): Boolean { + if (previous == null) return false + if (!previous.sender.equals(message.sender, ignoreCase = true)) return false + if (previous.kind in SYSTEM_EVENT_KINDS || message.kind in SYSTEM_EVENT_KINDS) return false + if (previous.kind == "action" || message.kind == "action") return false + if (!isSameDay(previous.serverTime, message.serverTime)) return false + val gap = message.serverTime - previous.serverTime + return gap in 0..gapWindowMs +} diff --git a/app/src/test/java/pm/antani/resentin/ui/chat/ChatTimelineTest.kt b/app/src/test/java/pm/antani/resentin/ui/chat/ChatTimelineTest.kt index 19eb521..8f9ff8e 100644 --- a/app/src/test/java/pm/antani/resentin/ui/chat/ChatTimelineTest.kt +++ b/app/src/test/java/pm/antani/resentin/ui/chat/ChatTimelineTest.kt @@ -1,9 +1,12 @@ package pm.antani.resentin.ui.chat import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import pm.antani.resentin.data.db.MessageEntity +import java.time.LocalDate +import java.time.ZoneId class ChatTimelineTest { @Test @@ -128,6 +131,56 @@ class ChatTimelineTest { assertEquals(rows.size, rows.filterIsInstance().size) } + @Test + fun sameDayHoldsWithinOneCalendarDay() { + val zone = ZoneId.systemDefault() + val midnight = LocalDate.now(zone).atStartOfDay(zone).toInstant().toEpochMilli() + + assertTrue(isSameDay(midnight, midnight + 3_600_000)) + assertTrue(isSameDay(midnight - 1_000, midnight - 1)) + assertFalse(isSameDay(midnight - 1, midnight)) + assertFalse(isSameDay(midnight, midnight + 24 * 3_600_000)) + } + + @Test + fun groupContinuesForSameSenderWithinWindow() { + val first = event(1, "privmsg", 1_000_000) + val second = event(2, "privmsg", 1_000_000 + 60_000) + + assertTrue(continuesMessageGroup(first, second)) + } + + @Test + fun groupBreaksOnSenderChangeGapSystemAndAction() { + val base = event(1, "privmsg", 1_000_000) + + assertFalse(continuesMessageGroup(null, base)) + assertFalse(continuesMessageGroup(base, event(2, "privmsg", 1_060_000, sender = "Other"))) + assertFalse(continuesMessageGroup(base, event(2, "privmsg", 1_000_000 + MESSAGE_GROUP_WINDOW_MS + 1))) + assertFalse(continuesMessageGroup(base, event(2, "join", 1_060_000))) + assertFalse(continuesMessageGroup(event(1, "join", 1_000_000), event(2, "privmsg", 1_060_000))) + assertFalse(continuesMessageGroup(base, event(2, "action", 1_060_000))) + assertFalse(continuesMessageGroup(event(1, "action", 1_000_000), event(2, "privmsg", 1_060_000))) + } + + @Test + fun groupBreaksAcrossMidnightEvenWithinWindow() { + val zone = ZoneId.systemDefault() + val midnight = LocalDate.now(zone).atStartOfDay(zone).toInstant().toEpochMilli() + val before = event(1, "privmsg", midnight - 60_000) + val after = event(2, "privmsg", midnight + 60_000) + + assertFalse(continuesMessageGroup(before, after)) + } + + @Test + fun groupIgnoresSenderCase() { + val first = event(1, "privmsg", 1_000_000, sender = "Nick") + val second = event(2, "privmsg", 1_060_000, sender = "nICK") + + assertTrue(continuesMessageGroup(first, second)) + } + private fun event(id: Long, kind: String, time: Long, sender: String = "Nick") = MessageEntity( networkSlug = "test", channelName = "#test",