Merge pull request #2875 from vitorpamplona/claude/timeago-toggle-format-Y3lGX
Make timestamps toggleable between relative and absolute formats
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
+5
-17
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+5
-17
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+41
@@ -21,7 +21,10 @@
|
||||
package com.vitorpamplona.amethyst.commons.util
|
||||
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
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 yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale)
|
||||
private var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
|
||||
private var timeOnlyFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, locale)
|
||||
|
||||
private fun updateFormattersIfNeeded() {
|
||||
if (locale != Locale.getDefault()) {
|
||||
locale = Locale.getDefault()
|
||||
yearFormatter = SimpleDateFormat(YEAR_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.
|
||||
*/
|
||||
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)
|
||||
|
||||
+3
-3
@@ -56,13 +56,13 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPa
|
||||
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
|
||||
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.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createNotificationsSubscription
|
||||
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.hexToByteArrayOrNull
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
@@ -342,8 +342,8 @@ fun NotificationCard(notification: NotificationItem) {
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = notification.timestamp.toTimeAgo(withDot = false),
|
||||
ToggleableTimeAgoText(
|
||||
timestamp = notification.timestamp,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
)
|
||||
|
||||
+3
-3
@@ -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.ui.components.LoadingState
|
||||
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.ChatroomFeedViewModel
|
||||
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.MediaAttachmentRow
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
@@ -528,8 +528,8 @@ private fun MessageWithReactions(
|
||||
|
||||
// Timestamp
|
||||
note.createdAt()?.let { timestamp ->
|
||||
Text(
|
||||
text = timestamp.toTimeAgo(withDot = false),
|
||||
ToggleableTimeAgoText(
|
||||
timestamp = timestamp,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
)
|
||||
|
||||
+3
-3
@@ -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.MaterialSymbols
|
||||
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 kotlinx.coroutines.launch
|
||||
|
||||
@@ -363,8 +363,8 @@ private fun ConversationCard(
|
||||
|
||||
if (item.lastMessageTimestamp > 0) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = item.lastMessageTimestamp.toTimeAgo(withDot = false),
|
||||
ToggleableTimeAgoText(
|
||||
timestamp = item.lastMessageTimestamp,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
)
|
||||
|
||||
+72
@@ -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 },
|
||||
)
|
||||
}
|
||||
+3
-3
@@ -59,8 +59,8 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.Urls
|
||||
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.ui.components.ToggleableTimeAgoText
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer
|
||||
@@ -202,8 +202,8 @@ fun NoteCard(
|
||||
}
|
||||
|
||||
// Timestamp
|
||||
Text(
|
||||
text = note.createdAt.toTimeAgo(withDot = false),
|
||||
ToggleableTimeAgoText(
|
||||
timestamp = note.createdAt,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user