Skip to content
Open
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
40 changes: 32 additions & 8 deletions app/src/main/java/pm/antani/resentin/ui/chat/ChatScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -1237,6 +1237,7 @@ fun ChatScreen(
selectingMessageId = selectingMessageId,
tight = tight,
)
}
}
is ChatTimelineRow.PresenceSummary -> item(key = row.key) {
val burstKey = row.messages.first().id
Expand All @@ -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,
Expand Down Expand Up @@ -1287,6 +1296,7 @@ fun ChatScreen(
}
}
}
}
}
}
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 }
Expand Down
33 changes: 33 additions & 0 deletions app/src/main/java/pm/antani/resentin/ui/chat/ChatTimeline.kt
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
}
53 changes: 53 additions & 0 deletions app/src/test/java/pm/antani/resentin/ui/chat/ChatTimelineTest.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -128,6 +131,56 @@ class ChatTimelineTest {
assertEquals(rows.size, rows.filterIsInstance<ChatTimelineRow.Message>().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",
Expand Down