Merge pull request #2875 from vitorpamplona/claude/timeago-toggle-format-Y3lGX

Make timestamps toggleable between relative and absolute formats
This commit is contained in:
Vitor Pamplona
2026-05-13 15:40:10 -04:00
committed by GitHub
10 changed files with 278 additions and 75 deletions
@@ -21,11 +21,14 @@
package com.vitorpamplona.amethyst.ui.note
import android.content.Context
import android.text.format.DateFormat
import android.text.format.DateUtils
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.TimeUtils
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import kotlin.math.round
@@ -42,6 +45,51 @@ var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
var yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale)
var monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale)
/**
* Formats a Unix timestamp (seconds) as an absolute date/time string, picking the
* granularity from how far away the timestamp is:
* - same day → time only (e.g. "14:32" / "2:32 PM", locale-aware)
* - same year → "MMM dd, HH:mm"
* - older → "MMM dd, yyyy"
*
* Used by [com.vitorpamplona.amethyst.ui.note.elements.TimeAgo] when the user
* taps the relative timestamp to reveal the absolute one.
*/
fun timeAbsolute(
time: Long?,
context: Context,
prefix: String = "",
): String {
if (time == null) return " "
if (time == 0L) return prefix + stringRes(context, R.string.never)
if (locale != Locale.getDefault()) {
locale = Locale.getDefault()
yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale)
monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
}
val timeMs = time * 1000
val now = Calendar.getInstance()
val then = Calendar.getInstance().apply { timeInMillis = timeMs }
val sameYear = now.get(Calendar.YEAR) == then.get(Calendar.YEAR)
val sameDay = sameYear && now.get(Calendar.DAY_OF_YEAR) == then.get(Calendar.DAY_OF_YEAR)
val timeOfDay = DateFormat.getTimeFormat(context).format(Date(timeMs))
return when {
sameDay -> prefix + timeOfDay
sameYear -> prefix + monthFormatter.format(timeMs) + ", " + timeOfDay
else -> prefix + yearFormatter.format(timeMs)
}
}
fun timeAbsoluteNoDot(
time: Long?,
context: Context,
): String = timeAbsolute(time, context, prefix = "")
fun timeAgo(
time: Long?,
context: Context,
@@ -20,22 +20,110 @@
*/
package com.vitorpamplona.amethyst.ui.note.elements
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.note.timeAbsolute
import com.vitorpamplona.amethyst.ui.note.timeAbsoluteNoDot
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
/**
* Which relative-time format to use when the timestamp is *not* toggled to absolute.
* Absolute-mode uses [timeAbsolute] (dotted) or [timeAbsoluteNoDot] regardless.
*/
enum class TimeAgoStyle {
/** "• 5m", uses [timeAgo]. Used by note timestamps and chatroom row last-message time. */
Dotted,
/** "5m" (no dot, no leading space), uses [timeAgoShort]. Used by chat bubbles and channel headers. */
Short,
}
/**
* Core toggleable timestamp Text. All on-screen TimeAgo composables delegate to this.
*
* Behaviour:
* - Renders the timestamp relative to "now" (subscribing to [LocalNowSeconds]); on click,
* flips to an absolute date/time formatted by [timeAbsolute] / [timeAbsoluteNoDot].
* - When displaying the absolute form we **stop subscribing to the tick** — the
* `derivedStateOf` lambda doesn't read `nowState.value` in that branch, so the Text
* does not recompose every 30s for items the user has "frozen" to a specific time.
* - Uses [remember] (not `rememberSaveable`): the toggle is a transient peek, not state
* worth saving to the SavedStateRegistry for every visible+scrolled-past note in the
* feed. Recycling resets to relative, which matches how peeking should work.
* - A single [MutableInteractionSource] per item, no ripple — tiny timestamp Text
* doesn't benefit from the indication.
*
* Stable params: all values, so the Compose compiler can skip this entirely when nothing changes.
*/
@Composable
fun ToggleableTimeAgoText(
timestamp: Long,
modifier: Modifier = Modifier,
style: TimeAgoStyle = TimeAgoStyle.Dotted,
color: Color = LocalContentColor.current,
fontSize: TextUnit = TextUnit.Unspecified,
maxLines: Int = 1,
overflow: TextOverflow = TextOverflow.Clip,
) {
val context = LocalContext.current
val nowState = LocalNowSeconds.current
val nowStr = stringRes(id = R.string.now)
val interactionSource = remember { MutableInteractionSource() }
var showAbsolute by remember(timestamp) { mutableStateOf(false) }
val text by
remember(timestamp, context, style, nowState, nowStr) {
derivedStateOf {
if (showAbsolute) {
when (style) {
TimeAgoStyle.Dotted -> timeAbsolute(timestamp, context)
TimeAgoStyle.Short -> timeAbsoluteNoDot(timestamp, context)
}
} else {
// Read nowState only when displaying a relative time, so an item
// toggled to absolute doesn't recompose on every 30-second tick.
nowState.value
when (style) {
TimeAgoStyle.Dotted -> timeAgo(timestamp, context)
TimeAgoStyle.Short -> timeAgoShort(timestamp, nowStr)
}
}
}
}
Text(
text = text,
color = color,
fontSize = fontSize,
maxLines = maxLines,
overflow = overflow,
modifier =
modifier.clickable(
interactionSource = interactionSource,
indication = null,
) { showAbsolute = !showAbsolute },
)
}
@Composable
fun TimeAgo(note: Note) {
val time = note.createdAt() ?: return
@@ -44,22 +132,10 @@ fun TimeAgo(note: Note) {
@Composable
fun TimeAgo(time: Long) {
val context = LocalContext.current
// Subscribe to the shared coarse ticker; `derivedStateOf` ensures the Text only
// recomposes when the formatted string actually flips (e.g. 1m → 2m), not on every tick.
val nowState = LocalNowSeconds.current
val timeStr by
remember(time, context, nowState) {
derivedStateOf {
nowState.value
timeAgo(time, context = context)
}
}
Text(
text = timeStr,
ToggleableTimeAgoText(
timestamp = time,
style = TimeAgoStyle.Dotted,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
)
}
@@ -68,20 +144,10 @@ fun NormalTimeAgo(
baseNote: Note,
modifier: Modifier,
) {
val nowStr = stringRes(id = R.string.now)
val nowState = LocalNowSeconds.current
val time by
remember(baseNote, nowStr, nowState) {
derivedStateOf {
nowState.value
timeAgoShort(baseNote.createdAt() ?: 0L, nowStr)
}
}
Text(
text = time,
maxLines = 1,
val time = baseNote.createdAt() ?: 0L
ToggleableTimeAgoText(
timestamp = time,
style = TimeAgoStyle.Short,
overflow = TextOverflow.Ellipsis,
modifier = modifier,
)
@@ -26,8 +26,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -37,8 +35,8 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.note.elements.LocalNowSeconds
import com.vitorpamplona.amethyst.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font12SP
@@ -48,21 +46,11 @@ import com.vitorpamplona.quartz.nip40Expiration.expiration
@Composable
fun ChatTimeAgo(baseNote: Note) {
val nowStr = stringRes(id = R.string.now)
val nowState = LocalNowSeconds.current
val time by
remember(baseNote, nowStr, nowState) {
derivedStateOf {
nowState.value
timeAgoShort(baseNote.createdAt() ?: 0L, nowStr)
}
}
Text(
text = time,
ToggleableTimeAgoText(
timestamp = baseNote.createdAt() ?: 0L,
style = TimeAgoStyle.Short,
color = MaterialTheme.colorScheme.placeholderText,
fontSize = Font12SP,
maxLines = 1,
)
}
@@ -27,13 +27,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
@@ -61,8 +59,8 @@ import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull
import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent
import com.vitorpamplona.amethyst.ui.note.elements.LocalNowSeconds
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel
@@ -483,20 +481,10 @@ fun ChannelName(
@Composable
private fun TimeAgo(channelLastTime: Long?) {
if (channelLastTime == null) return
val context = LocalContext.current
val nowState = LocalNowSeconds.current
val timeAgo by
remember(channelLastTime, context, nowState) {
derivedStateOf {
nowState.value
timeAgo(channelLastTime, context)
}
}
Text(
text = timeAgo,
ToggleableTimeAgoText(
timestamp = channelLastTime,
style = TimeAgoStyle.Dotted,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
)
}