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:
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
+17
-1
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+12
-1
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user