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 package com.vitorpamplona.amethyst.ui.note
import android.content.Context import android.content.Context
import android.text.format.DateFormat
import android.text.format.DateUtils import android.text.format.DateUtils
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale import java.util.Locale
import kotlin.math.round 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 yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale)
var monthNoDayFormatter = SimpleDateFormat(MONTH_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( fun timeAgo(
time: Long?, time: Long?,
context: Context, context: Context,
@@ -20,22 +20,110 @@
*/ */
package com.vitorpamplona.amethyst.ui.note.elements 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note 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.timeAgo
import com.vitorpamplona.amethyst.ui.note.timeAgoShort import com.vitorpamplona.amethyst.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText 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 @Composable
fun TimeAgo(note: Note) { fun TimeAgo(note: Note) {
val time = note.createdAt() ?: return val time = note.createdAt() ?: return
@@ -44,22 +132,10 @@ fun TimeAgo(note: Note) {
@Composable @Composable
fun TimeAgo(time: Long) { fun TimeAgo(time: Long) {
val context = LocalContext.current ToggleableTimeAgoText(
// Subscribe to the shared coarse ticker; `derivedStateOf` ensures the Text only timestamp = time,
// recomposes when the formatted string actually flips (e.g. 1m → 2m), not on every tick. style = TimeAgoStyle.Dotted,
val nowState = LocalNowSeconds.current
val timeStr by
remember(time, context, nowState) {
derivedStateOf {
nowState.value
timeAgo(time, context = context)
}
}
Text(
text = timeStr,
color = MaterialTheme.colorScheme.placeholderText, color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
) )
} }
@@ -68,20 +144,10 @@ fun NormalTimeAgo(
baseNote: Note, baseNote: Note,
modifier: Modifier, modifier: Modifier,
) { ) {
val nowStr = stringRes(id = R.string.now) val time = baseNote.createdAt() ?: 0L
val nowState = LocalNowSeconds.current ToggleableTimeAgoText(
timestamp = time,
val time by style = TimeAgoStyle.Short,
remember(baseNote, nowStr, nowState) {
derivedStateOf {
nowState.value
timeAgoShort(baseNote.createdAt() ?: 0L, nowStr)
}
}
Text(
text = time,
maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = modifier, modifier = modifier,
) )
@@ -26,8 +26,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.note.elements.LocalNowSeconds import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.note.timeAgoShort import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.Font12SP
@@ -48,21 +46,11 @@ import com.vitorpamplona.quartz.nip40Expiration.expiration
@Composable @Composable
fun ChatTimeAgo(baseNote: Note) { fun ChatTimeAgo(baseNote: Note) {
val nowStr = stringRes(id = R.string.now) ToggleableTimeAgoText(
val nowState = LocalNowSeconds.current timestamp = baseNote.createdAt() ?: 0L,
val time by style = TimeAgoStyle.Short,
remember(baseNote, nowStr, nowState) {
derivedStateOf {
nowState.value
timeAgoShort(baseNote.createdAt() ?: 0L, nowStr)
}
}
Text(
text = time,
color = MaterialTheme.colorScheme.placeholderText, color = MaterialTheme.colorScheme.placeholderText,
fontSize = Font12SP, fontSize = Font12SP,
maxLines = 1,
) )
} }
@@ -27,13 +27,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight 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.LoadPublicChatChannel
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent
import com.vitorpamplona.amethyst.ui.note.elements.LocalNowSeconds import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.privateDM.header.RoomNameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel
@@ -483,20 +481,10 @@ fun ChannelName(
@Composable @Composable
private fun TimeAgo(channelLastTime: Long?) { private fun TimeAgo(channelLastTime: Long?) {
if (channelLastTime == null) return if (channelLastTime == null) return
ToggleableTimeAgoText(
val context = LocalContext.current timestamp = channelLastTime,
val nowState = LocalNowSeconds.current style = TimeAgoStyle.Dotted,
val timeAgo by
remember(channelLastTime, context, nowState) {
derivedStateOf {
nowState.value
timeAgo(channelLastTime, context)
}
}
Text(
text = timeAgo,
color = MaterialTheme.colorScheme.grayText, color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
) )
} }
@@ -21,7 +21,10 @@
package com.vitorpamplona.amethyst.commons.util package com.vitorpamplona.amethyst.commons.util
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import java.text.DateFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale import java.util.Locale
private const val YEAR_DATE_FORMAT = "MMM dd, yyyy" private const val YEAR_DATE_FORMAT = "MMM dd, yyyy"
@@ -30,12 +33,14 @@ private const val MONTH_DATE_FORMAT = "MMM dd"
private var locale = Locale.getDefault() private var locale = Locale.getDefault()
private var yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) private var yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale)
private var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) private var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
private var timeOnlyFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, locale)
private fun updateFormattersIfNeeded() { private fun updateFormattersIfNeeded() {
if (locale != Locale.getDefault()) { if (locale != Locale.getDefault()) {
locale = Locale.getDefault() locale = Locale.getDefault()
yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale)
monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
timeOnlyFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, locale)
} }
} }
@@ -145,3 +150,39 @@ fun dateFormatter(
* Extension function for Long timestamps. * Extension function for Long timestamps.
*/ */
fun Long.toTimeAgo(withDot: Boolean = true): String = timeAgo(this, withDot) fun Long.toTimeAgo(withDot: Boolean = true): String = timeAgo(this, withDot)
/**
* Formats a Unix timestamp (seconds) as an absolute date/time string. Granularity
* depends on how far in the past the timestamp is:
* - same day → time only (e.g. "14:32"), locale-aware
* - same year → "MMM dd, HH:mm"
* - older → "MMM dd, yyyy"
*/
fun timeAbsolute(
time: Long?,
withDot: Boolean = true,
never: String = "never",
): String {
if (time == null) return " "
val prefix = if (withDot) "" else ""
if (time == 0L) return prefix + never
updateFormattersIfNeeded()
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 = timeOnlyFormatter.format(Date(timeMs))
return when {
sameDay -> prefix + timeOfDay
sameYear -> prefix + monthFormatter.format(timeMs) + ", " + timeOfDay
else -> prefix + yearFormatter.format(timeMs)
}
}
fun Long.toTimeAbsolute(withDot: Boolean = true): String = timeAbsolute(this, withDot)
@@ -56,13 +56,13 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPa
import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.createNotificationsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createNotificationsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -342,8 +342,8 @@ fun NotificationCard(notification: NotificationItem) {
) )
} }
Text( ToggleableTimeAgoText(
text = notification.timestamp.toTimeAgo(withDot = false), timestamp = notification.timestamp,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
) )
@@ -84,10 +84,10 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState
import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel
import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker
import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
@@ -528,8 +528,8 @@ private fun MessageWithReactions(
// Timestamp // Timestamp
note.createdAt()?.let { timestamp -> note.createdAt()?.let { timestamp ->
Text( ToggleableTimeAgoText(
text = timestamp.toTimeAgo(withDot = false), timestamp = timestamp,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
) )
@@ -66,7 +66,7 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.commons.util.toTimeAgo import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -363,8 +363,8 @@ private fun ConversationCard(
if (item.lastMessageTimestamp > 0) { if (item.lastMessageTimestamp > 0) {
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text( ToggleableTimeAgoText(
text = item.lastMessageTimestamp.toTimeAgo(withDot = false), timestamp = item.lastMessageTimestamp,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
) )
@@ -0,0 +1,72 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.text.TextStyle
import com.vitorpamplona.amethyst.commons.util.timeAbsolute
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
/**
* Timestamp text that toggles between a relative ("5m") and an absolute, scale-adjusted
* ("14:32" / "Dec 12, 14:32" / "Dec 12, 2023") form when the user clicks it.
*
* Notes for the audit:
* - Plain [remember] (not `rememberSaveable`): the toggle is a transient peek. Persisting
* it for every visible+scrolled-past notification/chat/note across config changes is
* pure memory bloat recycling resets to relative, which is the right default.
* - Both formatters here are pure functions of the timestamp, so for desktop we don't need
* any tick subscription at all (desktop has no shared "now" ticker). The string is
* stable for a given (timestamp, showAbsolute) pair, so a [derivedStateOf] also isn't
* needed we just compute on flip.
* - All params are primitives/value types, so the Compose compiler can skip this composable
* when nothing changes.
*/
@Composable
fun ToggleableTimeAgoText(
timestamp: Long,
style: TextStyle,
color: Color,
modifier: Modifier = Modifier,
) {
val interactionSource = remember { MutableInteractionSource() }
var showAbsolute by remember(timestamp) { mutableStateOf(false) }
Text(
text = if (showAbsolute) timeAbsolute(timestamp, withDot = false) else timestamp.toTimeAgo(withDot = false),
style = style,
color = color,
modifier =
modifier.clickable(
interactionSource = interactionSource,
indication = null,
) { showAbsolute = !showAbsolute },
)
}
@@ -59,8 +59,8 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.richtext.Urls import com.vitorpamplona.amethyst.commons.richtext.Urls
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage
import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer
@@ -202,8 +202,8 @@ fun NoteCard(
} }
// Timestamp // Timestamp
Text( ToggleableTimeAgoText(
text = note.createdAt.toTimeAgo(withDot = false), timestamp = note.createdAt,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )