diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/HistoryDateFormat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/HistoryDateFormat.kt
new file mode 100644
index 0000000000..7b6ab94391
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/HistoryDateFormat.kt
@@ -0,0 +1,32 @@
+/*
+ * 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.ui.screen.loggedIn.chats.feed
+
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+/**
+ * The Android locale date formatter for the shared [com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard]
+ * — passed in so the shared (KMP) card carries no `java.time` dependency. Formats a paging reach point
+ * (epoch seconds) to a short month-year label, e.g. "Jun 2026".
+ */
+fun formatHistoryReachDate(epochSeconds: Long): String = SimpleDateFormat("MMM yyyy", Locale.getDefault()).format(Date(epochSeconds * 1000))
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt
index d606811988..695a15fcdd 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt
@@ -49,6 +49,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryRelayDialog
+import com.vitorpamplona.amethyst.commons.ui.feeds.historySubtitle
+import com.vitorpamplona.amethyst.commons.ui.feeds.incompleteSubtitle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
@@ -156,7 +159,7 @@ fun LoadingReplyNote(
// can see exactly which relays were reached and which stalled. Empty progress keeps it non-interactive.
var showRelays by remember { mutableStateOf(false) }
if (showRelays) {
- DmHistoryRelayDialog(protocolTag, relayProgress) { showRelays = false }
+ DmHistoryRelayDialog(protocolTag, relayProgress, ::formatHistoryReachDate) { showRelays = false }
}
// Same chrome as DmHistoryLoadingCard (the older-history status card at the oldest end) so an
@@ -224,7 +227,7 @@ fun LoadingReplyNote(
when {
stalledOut -> incompleteSubtitle(stalledCount)
isExhausted -> stringRes(R.string.chats_reply_searched)
- else -> historySubtitle(protocolTag, relayCount, stalledCount, reachedBack)
+ else -> historySubtitle(protocolTag, relayCount, stalledCount, reachedBack, ::formatHistoryReachDate)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt
index a856c1f8c2..decd0d2efe 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt
@@ -39,6 +39,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState
@@ -52,8 +53,8 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
-import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.DmHistoryLoadingCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.formatHistoryReachDate
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal.ChatroomFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel
@@ -273,8 +274,8 @@ fun ChatroomViewUI(
// while it pages and crossfades to "All caught up" when that protocol runs dry.
olderBoundary = {
Column {
- DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsExhausted, giftWrapsRelays, giftWrapsStalled, giftWrapsReached, giftWrapsProgress)
- DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Stalled, nip04Reached, nip04Progress)
+ DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsExhausted, giftWrapsRelays, giftWrapsStalled, giftWrapsReached, giftWrapsProgress, ::formatHistoryReachDate)
+ DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Stalled, nip04Reached, nip04Progress, ::formatHistoryReachDate)
}
},
// Each relay's window-limit marker, placed at its reached cursor (pure UI). Hidden once
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt
index 68620f2cb2..923c20473e 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt
@@ -37,6 +37,7 @@ import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
+import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState
@@ -53,7 +54,7 @@ import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
-import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.DmHistoryLoadingCard
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.formatHistoryReachDate
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderCompose
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@@ -230,10 +231,10 @@ private fun FeedLoaded(
// Rendered unconditionally at the protocol's oldest room so the card can run its own
// "All caught up" crossfade-and-collapse when that protocol exhausts.
if (index == oldestNip17Index) {
- DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsExhausted, giftWrapsRelays, giftWrapsStalled, giftWrapsReached, giftWrapsProgress)
+ DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsExhausted, giftWrapsRelays, giftWrapsStalled, giftWrapsReached, giftWrapsProgress, ::formatHistoryReachDate)
}
if (index == oldestNip04Index) {
- DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Stalled, nip04Reached, nip04Progress)
+ DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Stalled, nip04Reached, nip04Progress, ::formatHistoryReachDate)
}
// Per-relay window-limit markers/sentinels belonging in the gap toward the next-older room:
diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml
index da98c3d19e..baae5cad1b 100644
--- a/commons/src/commonMain/composeResources/values/strings.xml
+++ b/commons/src/commonMain/composeResources/values/strings.xml
@@ -55,6 +55,21 @@
User avatar
Navigate
-
+
Relay sync:
+ Older %1$s messages
+ All caught up
+ Reached the start of your %1$s messages
+ %1$s · %2$s · back to %3$s
+ %1$s · %2$s
+ waiting on %1$s
+ Some relays didn\'t respond
+ %1$s unreachable · tap to see which
+ %1$s · history by relay
+ back to %1$s
+ Dismiss
+
+ - %1$d relay
+ - %1$d relays
+
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DmLoadMoreIndicator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt
similarity index 77%
rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DmLoadMoreIndicator.kt
rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt
index be71ae466a..8388088669 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DmLoadMoreIndicator.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt
@@ -18,7 +18,7 @@
* 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.ui.screen.loggedIn.chats.feed
+package com.vitorpamplona.amethyst.commons.ui.feeds
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
@@ -54,33 +54,48 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.res.pluralStringResource
-import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
-import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.commons.resources.Res
+import com.vitorpamplona.amethyst.commons.resources.action_dismiss
+import com.vitorpamplona.amethyst.commons.resources.chats_history_all_caught_up
+import com.vitorpamplona.amethyst.commons.resources.chats_history_incomplete
+import com.vitorpamplona.amethyst.commons.resources.chats_history_incomplete_sub
+import com.vitorpamplona.amethyst.commons.resources.chats_history_older
+import com.vitorpamplona.amethyst.commons.resources.chats_history_reached_start
+import com.vitorpamplona.amethyst.commons.resources.chats_history_relay_back
+import com.vitorpamplona.amethyst.commons.resources.chats_history_relays
+import com.vitorpamplona.amethyst.commons.resources.chats_history_relays_title
+import com.vitorpamplona.amethyst.commons.resources.chats_history_subtitle
+import com.vitorpamplona.amethyst.commons.resources.chats_history_subtitle_no_date
+import com.vitorpamplona.amethyst.commons.resources.chats_history_waiting
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.delay
-import java.text.SimpleDateFormat
-import java.util.Date
-import java.util.Locale
+import org.jetbrains.compose.resources.pluralStringResource
+import org.jetbrains.compose.resources.stringResource
// How long the "All caught up" state lingers before the card collapses away.
private const val ALL_DONE_VISIBLE_MS = 2200L
/**
- * The DM "older history" status card, shown at one protocol's oldest-loaded boundary (rooms list and
- * conversation). It tells the user exactly what the app is reaching for: which protocol, how many
- * relays it is asking, and how far back it has paged. When that protocol runs dry it does NOT just
- * vanish — it crossfades to an "All caught up" state, holds for a beat, then collapses away.
+ * The "older history" status card for a per-relay [BackwardRelayPager]-backed feed, shown at one
+ * protocol's oldest-loaded boundary (rooms list and conversation). It tells the user exactly what the
+ * app is reaching for: which protocol, how many relays it is asking, and how far back it has paged.
+ * When that protocol runs dry it does NOT just vanish — it crossfades to an "All caught up" state,
+ * holds for a beat, then collapses away. When it stops short because relays stalled it says so and
+ * stays put (an *incomplete* window — messages may still be out there).
+ *
+ * Shared across front ends; pass the platform's locale date formatter as [formatReachDate] so the card
+ * carries no `java.time` / `NSDateFormatter` dependency.
*
* @param protocolName human label woven into sentences, e.g. "encrypted" / "legacy".
* @param protocolTag short technical tag for the subtitle, e.g. "NIP-17" / "NIP-04".
* @param reachedBack epoch seconds of the oldest point reached so far (the deepest `until` cursor).
* @param relayProgress per-relay reach (where each relay's window is, done/stalled). Tapping the card
* opens a popup listing them; pass empty to make the card non-interactive.
+ * @param formatReachDate formats an epoch-seconds reach point to a short label (e.g. "Jun 2026").
*/
@Composable
fun DmHistoryLoadingCard(
@@ -92,6 +107,7 @@ fun DmHistoryLoadingCard(
stalledCount: Int,
reachedBack: Long?,
relayProgress: Map = emptyMap(),
+ formatReachDate: (epochSeconds: Long) -> String,
modifier: Modifier = Modifier,
) {
// Exhausted ("nothing more reachable right now") splits two ways and must NOT read the same:
@@ -115,7 +131,7 @@ fun DmHistoryLoadingCard(
var showRelays by remember { mutableStateOf(false) }
if (showRelays) {
- DmHistoryRelayDialog(protocolTag, relayProgress) { showRelays = false }
+ DmHistoryRelayDialog(protocolTag, relayProgress, formatReachDate) { showRelays = false }
}
AnimatedVisibility(
@@ -169,9 +185,9 @@ fun DmHistoryLoadingCard(
if (loading) {
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
- // Paused: not caught up, but not actively loading (the rooms-list auto-fill
- // stopped short of exhaustion, or we're between round-model pages). Show a
- // static "more" glyph so the icon slot is never blank — resumes on scroll.
+ // Paused: not caught up, but not actively loading (the auto-fill stopped short
+ // of exhaustion, or we're between pages). Show a static "more" glyph so the
+ // icon slot is never blank — resumes on scroll.
Text(
"⋯",
style = MaterialTheme.typography.titleMedium,
@@ -186,9 +202,9 @@ fun DmHistoryLoadingCard(
Text(
text =
when (state) {
- HistoryPhase.CaughtUp -> stringResource(R.string.chats_history_all_caught_up)
- HistoryPhase.Incomplete -> stringResource(R.string.chats_history_incomplete)
- HistoryPhase.Loading -> stringResource(R.string.chats_history_older, protocolName)
+ HistoryPhase.CaughtUp -> stringResource(Res.string.chats_history_all_caught_up)
+ HistoryPhase.Incomplete -> stringResource(Res.string.chats_history_incomplete)
+ HistoryPhase.Loading -> stringResource(Res.string.chats_history_older, protocolName)
},
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
@@ -197,9 +213,9 @@ fun DmHistoryLoadingCard(
Text(
text =
when (state) {
- HistoryPhase.CaughtUp -> stringResource(R.string.chats_history_reached_start, protocolName)
+ HistoryPhase.CaughtUp -> stringResource(Res.string.chats_history_reached_start, protocolName)
HistoryPhase.Incomplete -> incompleteSubtitle(stalledCount)
- HistoryPhase.Loading -> historySubtitle(protocolTag, relayCount, stalledCount, reachedBack)
+ HistoryPhase.Loading -> historySubtitle(protocolTag, relayCount, stalledCount, reachedBack, formatReachDate)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -218,40 +234,38 @@ private enum class HistoryPhase { Loading, CaughtUp, Incomplete }
/** Subtitle for the "stopped early" state: how many relays we couldn't reach, with a hint to tap for the list.
* Shared with the reply placeholder so both read identically. */
@Composable
-internal fun incompleteSubtitle(stalledCount: Int): String =
+fun incompleteSubtitle(stalledCount: Int): String =
stringResource(
- R.string.chats_history_incomplete_sub,
- pluralStringResource(R.plurals.chats_history_relays, stalledCount, stalledCount),
+ Res.string.chats_history_incomplete_sub,
+ pluralStringResource(Res.plurals.chats_history_relays, stalledCount, stalledCount),
)
@Composable
-internal fun historySubtitle(
+fun historySubtitle(
protocolTag: String,
relayCount: Int,
stalledCount: Int,
reachedBack: Long?,
+ formatReachDate: (epochSeconds: Long) -> String,
): String {
- val backLabel =
- remember(reachedBack) {
- reachedBack?.let { SimpleDateFormat("MMM yyyy", Locale.getDefault()).format(Date(it * 1000)) }
- }
+ val backLabel = remember(reachedBack) { reachedBack?.let(formatReachDate) }
// Middle segment: the relays actively fetching ("N relays"), or — when none are in flight but some
// can't be reached — what we're waiting on ("waiting on N relays"). With neither, just the tag, since
// a paged-out-but-parked protocol isn't waiting on anything (it resumes on scroll).
val middle =
when {
- relayCount > 0 -> pluralStringResource(R.plurals.chats_history_relays, relayCount, relayCount)
+ relayCount > 0 -> pluralStringResource(Res.plurals.chats_history_relays, relayCount, relayCount)
stalledCount > 0 ->
stringResource(
- R.string.chats_history_waiting,
- pluralStringResource(R.plurals.chats_history_relays, stalledCount, stalledCount),
+ Res.string.chats_history_waiting,
+ pluralStringResource(Res.plurals.chats_history_relays, stalledCount, stalledCount),
)
else -> return protocolTag
}
return if (backLabel != null) {
- stringResource(R.string.chats_history_subtitle, protocolTag, middle, backLabel)
+ stringResource(Res.string.chats_history_subtitle, protocolTag, middle, backLabel)
} else {
- stringResource(R.string.chats_history_subtitle_no_date, protocolTag, middle)
+ stringResource(Res.string.chats_history_subtitle_no_date, protocolTag, middle)
}
}
@@ -260,19 +274,19 @@ internal fun historySubtitle(
* ↓ still reaching) and how far back it has paged ("back to "), deepest-reaching first.
*/
@Composable
-internal fun DmHistoryRelayDialog(
+fun DmHistoryRelayDialog(
protocolTag: String,
relayProgress: Map,
+ formatReachDate: (epochSeconds: Long) -> String,
onDismiss: () -> Unit,
) {
- val df = remember { SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) }
val rows = remember(relayProgress) { relayProgress.entries.sortedBy { it.value.reachedUntil } }
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
- TextButton(onClick = onDismiss) { Text(stringResource(R.string.dismiss)) }
+ TextButton(onClick = onDismiss) { Text(stringResource(Res.string.action_dismiss)) }
},
- title = { Text(stringResource(R.string.chats_history_relays_title, protocolTag)) },
+ title = { Text(stringResource(Res.string.chats_history_relays_title, protocolTag)) },
text = {
Column(
Modifier
@@ -301,7 +315,7 @@ internal fun DmHistoryRelayDialog(
)
Spacer(Modifier.width(8.dp))
Text(
- text = stringResource(R.string.chats_history_relay_back, df.format(Date(p.reachedUntil * 1000))),
+ text = stringResource(Res.string.chats_history_relay_back, formatReachDate(p.reachedUntil)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,