feat: tap TimeAgo to toggle relative ↔ absolute timestamp

Every TimeAgo composable (Android NoteCompose timestamp, NormalTimeAgo,
ChatTimeAgo, ChatroomHeaderCompose last-message time) and every Desktop
timestamp Text (NoteCard, NotificationsScreen, ChatPane, ConversationListPane)
is now clickable and toggles to a scale-adjusted absolute date/time:

  - same day → time only (e.g. "14:32"), locale-aware
  - same year → "MMM dd, HH:mm"
  - older    → "MMM dd, yyyy"

State is hoisted per-call site via rememberSaveable so the toggle survives
scroll-induced disposal in lazy lists. Desktop call sites share a small
ToggleableTimeAgoText wrapper; Android keeps its existing composable shapes
and just gains a clickable modifier + state.

https://claude.ai/code/session_01AuPon9VQeRfKV1BTVQuKGC
This commit is contained in:
Claude
2026-05-13 19:17:55 +00:00
parent 781e8484ac
commit 38463e5f2c
10 changed files with 223 additions and 17 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,17 +20,24 @@
*/
package com.vitorpamplona.amethyst.ui.note.elements
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
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
@@ -48,11 +55,14 @@ fun TimeAgo(time: Long) {
// 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
var showAbsolute by rememberSaveable(time) { mutableStateOf(false) }
val interactionSource = remember { MutableInteractionSource() }
val timeStr by
remember(time, context, nowState) {
derivedStateOf {
nowState.value
timeAgo(time, context = context)
if (showAbsolute) timeAbsolute(time, context) else timeAgo(time, context = context)
}
}
@@ -60,6 +70,11 @@ fun TimeAgo(time: Long) {
text = timeStr,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
modifier =
Modifier.clickable(
interactionSource = interactionSource,
indication = null,
) { showAbsolute = !showAbsolute },
)
}
@@ -70,12 +85,16 @@ fun NormalTimeAgo(
) {
val nowStr = stringRes(id = R.string.now)
val nowState = LocalNowSeconds.current
val context = LocalContext.current
var showAbsolute by rememberSaveable(baseNote.idHex) { mutableStateOf(false) }
val interactionSource = remember { MutableInteractionSource() }
val time by
remember(baseNote, nowStr, nowState) {
derivedStateOf {
nowState.value
timeAgoShort(baseNote.createdAt() ?: 0L, nowStr)
val createdAt = baseNote.createdAt() ?: 0L
if (showAbsolute) timeAbsoluteNoDot(createdAt, context) else timeAgoShort(createdAt, nowStr)
}
}
@@ -83,6 +102,10 @@ fun NormalTimeAgo(
text = time,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier,
modifier =
modifier.clickable(
interactionSource = interactionSource,
indication = null,
) { showAbsolute = !showAbsolute },
)
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
@@ -28,7 +30,10 @@ 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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -38,6 +43,7 @@ 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.timeAbsoluteNoDot
import com.vitorpamplona.amethyst.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.stringRes
@@ -50,11 +56,16 @@ import com.vitorpamplona.quartz.nip40Expiration.expiration
fun ChatTimeAgo(baseNote: Note) {
val nowStr = stringRes(id = R.string.now)
val nowState = LocalNowSeconds.current
val context = LocalContext.current
var showAbsolute by rememberSaveable(baseNote.idHex) { mutableStateOf(false) }
val interactionSource = remember { MutableInteractionSource() }
val time by
remember(baseNote, nowStr, nowState) {
derivedStateOf {
nowState.value
timeAgoShort(baseNote.createdAt() ?: 0L, nowStr)
val createdAt = baseNote.createdAt() ?: 0L
if (showAbsolute) timeAbsoluteNoDot(createdAt, context) else timeAgoShort(createdAt, nowStr)
}
}
@@ -63,6 +74,11 @@ fun ChatTimeAgo(baseNote: Note) {
color = MaterialTheme.colorScheme.placeholderText,
fontSize = Font12SP,
maxLines = 1,
modifier =
Modifier.clickable(
interactionSource = interactionSource,
indication = null,
) { showAbsolute = !showAbsolute },
)
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.LocalTextStyle
@@ -31,6 +33,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -62,6 +65,7 @@ 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.timeAbsolute
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay
@@ -486,17 +490,24 @@ private fun TimeAgo(channelLastTime: Long?) {
val context = LocalContext.current
val nowState = LocalNowSeconds.current
var showAbsolute by rememberSaveable(channelLastTime) { mutableStateOf(false) }
val interactionSource = remember { MutableInteractionSource() }
val timeAgo by
remember(channelLastTime, context, nowState) {
derivedStateOf {
nowState.value
timeAgo(channelLastTime, context)
if (showAbsolute) timeAbsolute(channelLastTime, context) else timeAgo(channelLastTime, context)
}
}
Text(
text = timeAgo,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
modifier =
Modifier.clickable(
interactionSource = interactionSource,
indication = null,
) { showAbsolute = !showAbsolute },
)
}