Merge pull request #3095 from vitorpamplona/claude/amazing-ptolemy-26Nek

Use locale-aware date/time formatting throughout the app
This commit is contained in:
Vitor Pamplona
2026-05-29 08:30:41 -04:00
committed by GitHub
23 changed files with 297 additions and 269 deletions
@@ -0,0 +1,74 @@
/*
* 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.note
import android.content.Context
import android.text.format.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Date/time formatters that respect the user's Android system settings:
* - Date order (dd/mm/yyyy, mm/dd/yyyy, yyyy-mm-dd…) is derived from the
* active [Locale] via [DateFormat.getBestDateTimePattern].
* - 12-hour vs 24-hour clock follows the system setting via
* [DateFormat.is24HourFormat] (which is the user's manual override on
* top of the locale default).
*
* All formatters here use Unicode LDML skeletons. `j` and `jm` are intentionally
* avoided — `j` only picks 12/24 hour from the *locale*, not the user override.
* We pick the time skeleton explicitly based on [DateFormat.is24HourFormat].
*/
private fun timeSkeleton(context: Context): String = if (DateFormat.is24HourFormat(context)) "Hm" else "hma"
private fun bestPattern(
context: Context,
skeletonBase: String,
includeTime: Boolean,
): SimpleDateFormat {
val locale = Locale.getDefault()
val skeleton = if (includeTime) skeletonBase + timeSkeleton(context) else skeletonBase
return SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, skeleton), locale)
}
/** Locale-aware month + day + short time (e.g. "May 28, 14:32" / "28 May, 2:32 PM"). */
fun formatMonthDayTime(
epochSeconds: Long,
context: Context,
): String = bestPattern(context, "MMMd", includeTime = true).format(Date(epochSeconds * 1000L))
/** Locale-aware medium date (e.g. "May 28, 2026" / "28 May 2026" / "28.05.2026"). */
fun formatMediumDate(
epochSeconds: Long,
context: Context,
): String = DateFormat.getMediumDateFormat(context).format(Date(epochSeconds * 1000L))
/** Locale-aware medium date + short time, respecting the system 12/24-hour setting. */
fun formatMediumDateTime(
epochSeconds: Long,
context: Context,
): String {
val date = DateFormat.getMediumDateFormat(context).format(Date(epochSeconds * 1000L))
val time = DateFormat.getTimeFormat(context).format(Date(epochSeconds * 1000L))
return "$date $time"
}
@@ -50,9 +50,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
fun RelayCompose(
@@ -145,9 +142,3 @@ fun RemoveRelayButton(onClick: () -> Unit) {
Text(text = stringRes(R.string.remove))
}
}
fun formattedDateTime(timestamp: Long): String =
Instant
.ofEpochSecond(timestamp)
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("MMM d, uuuu hh:mm a"))
@@ -32,25 +32,47 @@ import java.util.Date
import java.util.Locale
import kotlin.math.round
private const val YEAR_DATE_FORMAT = "MMM dd, yyyy"
private const val MONTH_DATE_FORMAT = "MMM dd"
// Skeletons follow Unicode LDML — DateFormat.getBestDateTimePattern picks the
// correct locale-specific ordering (e.g. "MMM d, y" in en-US vs "d MMM y" in en-GB).
private const val YEAR_SKELETON = "yMMMd"
private const val MONTH_SKELETON = "MMMd"
private const val YEAR_NO_DAY_SKELETON = "yMMM"
private const val YEAR_NO_DAY_DATE_FORMAT = "MMM yyyy"
private const val MONTH_NO_DAY_DATE_FORMAT = "MMM dd"
/**
* Per-thread cached [SimpleDateFormat] keyed off the current default [Locale].
*
* `SimpleDateFormat` is mutable and not thread-safe, and these formatters are
* read from both the UI thread (composition) and background coroutines
* (e.g. `LocalCache.justVerify` logging failed event verifications). A bare
* `var` shared across threads would race on the formatter's internal Calendar.
* Using `ThreadLocal` gives each thread its own instance — no locks, no
* allocation per call, and we rebuild lazily on locale change.
*/
private class LocaleAwareFormatter(
private val skeleton: String,
) {
private val cache = ThreadLocal<Pair<Locale, SimpleDateFormat>>()
var locale: Locale = Locale.getDefault()
var yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale)
var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
fun get(): SimpleDateFormat {
val current = Locale.getDefault()
val cached = cache.get()
if (cached != null && cached.first == current) return cached.second
val fresh = SimpleDateFormat(DateFormat.getBestDateTimePattern(current, skeleton), current)
cache.set(current to fresh)
return fresh
}
}
var yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale)
var monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale)
private val yearFormatter = LocaleAwareFormatter(YEAR_SKELETON)
private val monthFormatter = LocaleAwareFormatter(MONTH_SKELETON)
private val yearNoDayFormatter = LocaleAwareFormatter(YEAR_NO_DAY_SKELETON)
/**
* 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"
* - same day → time only (locale + system 12/24-hr aware via [DateFormat.getTimeFormat])
* - same year → "Jan 5, 14:32" / "5 Jan 14:32" / "Jan 5, 2:32 PM" (locale + system aware)
* - older → "Jan 5, 2024" / "5 Jan 2024" (locale aware)
*
* Used by [com.vitorpamplona.amethyst.ui.note.elements.TimeAgo] when the user
* taps the relative timestamp to reveal the absolute one.
@@ -63,12 +85,6 @@ fun timeAbsolute(
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 }
@@ -80,8 +96,8 @@ fun timeAbsolute(
return when {
sameDay -> prefix + timeOfDay
sameYear -> prefix + monthFormatter.format(timeMs) + ", " + timeOfDay
else -> prefix + yearFormatter.format(timeMs)
sameYear -> prefix + monthFormatter.get().format(timeMs) + ", " + timeOfDay
else -> prefix + yearFormatter.get().format(timeMs)
}
}
@@ -106,26 +122,11 @@ fun timeAgo(
return when {
timeDifference > TimeUtils.ONE_YEAR -> {
// Dec 12, 2022
if (locale != Locale.getDefault()) {
locale = Locale.getDefault()
yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale)
monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
}
prefix + yearFormatter.format(time * 1000)
prefix + yearFormatter.get().format(time * 1000)
}
timeDifference > TimeUtils.ONE_MONTH -> {
// Dec 12
if (locale != Locale.getDefault()) {
locale = Locale.getDefault()
yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale)
monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
}
prefix + monthFormatter.format(time * 1000)
prefix + monthFormatter.get().format(time * 1000)
}
timeDifference > TimeUtils.ONE_DAY -> {
@@ -157,26 +158,11 @@ fun timeAgoNoDot(
return when {
timeDifference > TimeUtils.ONE_YEAR -> {
// Dec 12, 2022
if (locale != Locale.getDefault()) {
locale = Locale.getDefault()
yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale)
monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
}
yearFormatter.format(time * 1000)
yearFormatter.get().format(time * 1000)
}
timeDifference > TimeUtils.ONE_MONTH -> {
// Dec 12
if (locale != Locale.getDefault()) {
locale = Locale.getDefault()
yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale)
monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
}
monthFormatter.format(time * 1000)
monthFormatter.get().format(time * 1000)
}
timeDifference > TimeUtils.ONE_DAY -> {
@@ -208,26 +194,11 @@ fun timeAgoNoDotNoDay(
return when {
timeDifference > TimeUtils.ONE_YEAR -> {
// Dec 12, 2022
if (locale != Locale.getDefault()) {
locale = Locale.getDefault()
yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale)
monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale)
}
yearNoDayFormatter.format(time * 1000)
yearNoDayFormatter.get().format(time * 1000)
}
timeDifference > TimeUtils.ONE_MONTH -> {
// Dec 12
if (locale != Locale.getDefault()) {
locale = Locale.getDefault()
yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale)
monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale)
}
monthNoDayFormatter.format(time * 1000)
monthFormatter.get().format(time * 1000)
}
timeDifference > TimeUtils.ONE_DAY -> {
@@ -259,26 +230,11 @@ fun timeAheadNoDot(
return when {
timeDifference > TimeUtils.ONE_YEAR -> {
// Dec 12, 2022
if (locale != Locale.getDefault()) {
locale = Locale.getDefault()
yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale)
monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
}
yearFormatter.format(time * 1000)
yearFormatter.get().format(time * 1000)
}
timeDifference > TimeUtils.ONE_MONTH -> {
// Dec 12
if (locale != Locale.getDefault()) {
locale = Locale.getDefault()
yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale)
monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
}
monthFormatter.format(time * 1000)
monthFormatter.get().format(time * 1000)
}
timeDifference > TimeUtils.ONE_DAY -> {
@@ -310,24 +266,9 @@ fun dateFormatter(
val timeDifference = TimeUtils.now() - time
return if (timeDifference > TimeUtils.ONE_YEAR) {
// Dec 12, 2022
if (locale != Locale.getDefault()) {
locale = Locale.getDefault()
yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale)
monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
}
yearFormatter.format(time * 1000)
yearFormatter.get().format(time * 1000)
} else if (timeDifference > TimeUtils.ONE_DAY) {
// Dec 12
if (locale != Locale.getDefault()) {
locale = Locale.getDefault()
yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale)
monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
}
monthFormatter.format(time * 1000)
monthFormatter.get().format(time * 1000)
} else {
today
}
@@ -409,12 +350,7 @@ fun lastSeenSentence(
}
}
if (locale != Locale.getDefault()) {
locale = Locale.getDefault()
yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale)
monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
}
val dateText = yearFormatter.format(time * 1000)
val dateText = yearFormatter.get().format(time * 1000)
return stringRes(context, R.string.last_seen_on_date, dateText, durationText)
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.expiration
import android.text.format.DateFormat
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -83,15 +84,14 @@ fun ExpirationDatePicker(model: IExpiration) {
},
)
val context = LocalContext.current
val timePickerState =
rememberTimePickerState(
initialHour = currentTime.hour,
initialMinute = currentTime.minute,
is24Hour = false,
is24Hour = DateFormat.is24HourFormat(context),
)
val context = LocalContext.current
Column(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.note.creators.polls
import android.annotation.SuppressLint
import android.text.format.DateFormat
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -81,15 +82,14 @@ fun PollDeadlinePicker(model: ShortNotePostViewModel) {
}
},
)
val context = LocalContext.current
val timePickerState =
rememberTimePickerState(
initialHour = currentTime.hour,
initialMinute = currentTime.minute,
is24Hour = false, // Set to true if you prefer military time
is24Hour = DateFormat.is24HourFormat(context),
)
val context = LocalContext.current
OutlinedCard(
onClick = { showDatePicker = true },
modifier = Modifier.fillMaxWidth(),
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.note.creators.zappolls
import android.annotation.SuppressLint
import android.text.format.DateFormat
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -81,15 +82,14 @@ fun ZapPollDeadlinePicker(model: ShortNotePostViewModel) {
}
},
)
val context = LocalContext.current
val timePickerState =
rememberTimePickerState(
initialHour = currentTime.hour,
initialMinute = currentTime.minute,
is24Hour = false, // Set to true if you prefer military time
is24Hour = DateFormat.is24HourFormat(context),
)
val context = LocalContext.current
OutlinedCard(
onClick = { showDatePicker = true },
modifier = Modifier.fillMaxWidth(),
@@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -63,6 +64,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.formatMediumDate
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
@@ -76,9 +78,6 @@ import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Attes
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Preview
@Composable
@@ -188,18 +187,19 @@ fun RenderAttestation(
}
if (validFrom != null || validTo != null) {
val context = LocalContext.current
Spacer(modifier = DoubleVertSpacer)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
validFrom?.let {
Text(
text = stringRes(R.string.attestation_valid_from, formatTimestamp(it)),
text = stringRes(R.string.attestation_valid_from, formatMediumDate(it, context)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
validTo?.let {
Text(
text = stringRes(R.string.attestation_valid_to, formatTimestamp(it)),
text = stringRes(R.string.attestation_valid_to, formatMediumDate(it, context)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -601,8 +601,3 @@ private fun attestationStatusLabel(status: AttestationStatus?): String =
status == AttestationStatus.VERIFYING -> stringRes(R.string.attestation_status_verifying)
else -> stringRes(R.string.attestation)
}
private fun formatTimestamp(timestamp: Long): String {
val sdf = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
return sdf.format(Date(timestamp * 1000))
}
@@ -46,6 +46,7 @@ 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
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -164,6 +165,7 @@ private fun DayRow(
}
val startSeconds = view.startSeconds
val context = LocalContext.current
val timeLabel =
when {
view.isAllDay -> stringRes(R.string.calendar_all_day)
@@ -172,7 +174,7 @@ private fun DayRow(
// misleading on day 2 since the event has been ongoing overnight. Show a
// continuation marker so the user reads it as "still happening".
stringRes(R.string.calendar_continues)
startSeconds != null -> formatTimeOfDay(startSeconds)
startSeconds != null -> formatTimeOfDay(startSeconds, context)
else -> ""
}
@@ -72,8 +72,8 @@ fun CalendarEventListCard(
modifier: Modifier = Modifier,
) {
val view = note.appointmentView() ?: return
val range = remember(note.idHex) { formatCalendarRange(note) }
val context = LocalContext.current
val range = remember(note.idHex) { formatCalendarRange(note, context) }
val relative = remember(note.idHex, view.startSeconds) { relativeTimeLabel(context, view, TimeUtils.now()) }
val event = note.event ?: return
val detailRoute =
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars
import android.content.Context
import android.text.format.DateFormat
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.model.Note
import java.text.SimpleDateFormat
@@ -27,34 +29,73 @@ import java.util.Calendar
import java.util.Date
import java.util.Locale
private val DayMonthFormat = SimpleDateFormat("EEE, MMM d", Locale.getDefault())
private val FullDateFormat = SimpleDateFormat("EEEE, MMMM d, yyyy", Locale.getDefault())
private val MonthYearFormat = SimpleDateFormat("MMMM yyyy", Locale.getDefault())
private val TimeFormat = SimpleDateFormat("h:mm a", Locale.getDefault())
private val WeekdayShortFormat = SimpleDateFormat("EEE", Locale.getDefault())
// Skeletons follow Unicode LDML — getBestDateTimePattern picks locale ordering.
private const val DAY_MONTH_SKELETON = "EEEMMMd" // "Mon, May 28" / "Mon 28 May"
private const val FULL_DATE_SKELETON = "EEEEMMMMdy" // "Monday, May 28, 2026" / "Monday, 28 May 2026"
private const val MONTH_YEAR_SKELETON = "MMMMy" // "May 2026" / "Mai 2026"
private const val WEEKDAY_SHORT_SKELETON = "EEE" // "Mon" — order doesn't matter
fun formatCalendarRange(note: Note): String? {
/**
* Per-thread cached [SimpleDateFormat] keyed off the current default [Locale].
*
* Cached because these run per cell in the calendar grid (42 cells per month,
* 7 per weekday header) allocating a fresh SimpleDateFormat each call
* showed up as scroll-time GC churn. Per-thread because SimpleDateFormat
* isn't thread-safe; calendars today are read on the UI thread but future
* callers could differ. ThreadLocal gives both no locks, no contention,
* lazy rebuild on locale change.
*/
private class LocaleAwareFormatter(
private val skeleton: String,
) {
private val cache = ThreadLocal<Pair<Locale, SimpleDateFormat>>()
fun get(): SimpleDateFormat {
val current = Locale.getDefault()
val cached = cache.get()
if (cached != null && cached.first == current) return cached.second
val fresh = SimpleDateFormat(DateFormat.getBestDateTimePattern(current, skeleton), current)
cache.set(current to fresh)
return fresh
}
}
private val dayMonthFormat = LocaleAwareFormatter(DAY_MONTH_SKELETON)
private val fullDateFormat = LocaleAwareFormatter(FULL_DATE_SKELETON)
private val monthYearFormat = LocaleAwareFormatter(MONTH_YEAR_SKELETON)
private val weekdayShortFormat = LocaleAwareFormatter(WEEKDAY_SHORT_SKELETON)
// Time format respects the user's Android 12/24-hour system setting.
private fun timeFormat(context: Context): java.text.DateFormat = DateFormat.getTimeFormat(context)
fun formatCalendarRange(
note: Note,
context: Context,
): String? {
val view = note.appointmentView() ?: return null
val start = view.startSeconds ?: return null
return if (view.isAllDay) {
formatDateRange(start, view.endSeconds)
} else {
formatTimeRange(start, view.endSeconds)
formatTimeRange(start, view.endSeconds, context)
}
}
private fun formatTimeRange(
start: Long,
end: Long?,
context: Context,
): String {
val dayMonth = dayMonthFormat.get()
val time = timeFormat(context)
val startMs = start * 1000
val startStr = "${DayMonthFormat.format(Date(startMs))} · ${TimeFormat.format(Date(startMs))}"
val startStr = "${dayMonth.format(Date(startMs))} · ${time.format(Date(startMs))}"
if (end == null || end == start) return startStr
val endMs = end * 1000
return if (isSameDay(startMs, endMs)) {
"$startStr ${TimeFormat.format(Date(endMs))}"
"$startStr ${time.format(Date(endMs))}"
} else {
"$startStr ${DayMonthFormat.format(Date(endMs))} · ${TimeFormat.format(Date(endMs))}"
"$startStr ${dayMonth.format(Date(endMs))} · ${time.format(Date(endMs))}"
}
}
@@ -62,12 +103,13 @@ private fun formatDateRange(
start: Long,
end: Long?,
): String {
val startStr = DayMonthFormat.format(Date(start * 1000))
val dayMonth = dayMonthFormat.get()
val startStr = dayMonth.format(Date(start * 1000))
if (end == null || end == start) return startStr
return "$startStr ${DayMonthFormat.format(Date(end * 1000))}"
return "$startStr ${dayMonth.format(Date(end * 1000))}"
}
fun formatLongDate(unixSeconds: Long): String = FullDateFormat.format(Date(unixSeconds * 1000))
fun formatLongDate(unixSeconds: Long): String = fullDateFormat.get().format(Date(unixSeconds * 1000))
fun formatMonthYear(
year: Int,
@@ -76,10 +118,13 @@ fun formatMonthYear(
val cal = Calendar.getInstance()
cal.clear()
cal.set(year, monthZeroBased, 1)
return MonthYearFormat.format(cal.time)
return monthYearFormat.get().format(cal.time)
}
fun formatTimeOfDay(unixSeconds: Long): String = TimeFormat.format(Date(unixSeconds * 1000))
fun formatTimeOfDay(
unixSeconds: Long,
context: Context,
): String = timeFormat(context).format(Date(unixSeconds * 1000))
fun formatShortWeekday(weekdayZeroBased: Int): String {
val cal = Calendar.getInstance()
@@ -87,7 +132,7 @@ fun formatShortWeekday(weekdayZeroBased: Int): String {
cal.firstDayOfWeek = Calendar.SUNDAY
cal.set(Calendar.DAY_OF_WEEK, Calendar.SUNDAY)
cal.add(Calendar.DAY_OF_YEAR, weekdayZeroBased)
return WeekdayShortFormat.format(cal.time)
return weekdayShortFormat.get().format(cal.time)
}
private fun isSameDay(
@@ -38,14 +38,15 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.Date
import android.text.format.DateFormat as AndroidDateFormat
/**
* Tap-to-edit button that opens a Material3 DatePicker (and, when [includeTime] is true,
@@ -68,6 +69,7 @@ fun CalendarDateTimePickerButton(
var showTime by remember { mutableStateOf(false) }
val locale = LocalConfiguration.current.locales[0]
val context = LocalContext.current
val pretty =
if (unixSeconds <= 0L) {
placeholder
@@ -76,7 +78,7 @@ fun CalendarDateTimePickerButton(
.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT, locale)
.format(Date(unixSeconds * 1000))
} else {
SimpleDateFormat("EEEE, MMMM d, yyyy", locale).format(Date(unixSeconds * 1000))
DateFormat.getDateInstance(DateFormat.FULL, locale).format(Date(unixSeconds * 1000))
}
val initialMillis = if (unixSeconds > 0L) unixSeconds * 1000L else System.currentTimeMillis()
@@ -94,7 +96,7 @@ fun CalendarDateTimePickerButton(
rememberTimePickerState(
initialHour = initialLocal.hour,
initialMinute = initialLocal.minute,
is24Hour = false,
is24Hour = AndroidDateFormat.is24HourFormat(context),
)
fun reset() {
@@ -318,14 +318,14 @@ private fun EventBody(
fontWeight = FontWeight.Bold,
)
}
formatCalendarRange(note)?.let { range ->
val context = LocalContext.current
formatCalendarRange(note, context)?.let { range ->
Text(
text = range,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
)
}
val context = LocalContext.current
val relative =
remember(note.idHex, view.startSeconds) {
relativeTimeLabel(context, view, TimeUtils.now())
@@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBri
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.launch
import android.text.format.DateFormat as AndroidDateFormat
/**
* Bottom sheet that lets the logged-in user start a new NIP-53 kind 30312
@@ -248,6 +249,8 @@ private fun ScheduleStartPicker(
var showDate by remember { mutableStateOf(false) }
var showTime by remember { mutableStateOf(false) }
val context = LocalContext.current
val pretty =
if (unixSeconds <= 0L) {
stringRes(R.string.nest_create_when)
@@ -277,7 +280,7 @@ private fun ScheduleStartPicker(
androidx.compose.material3.rememberTimePickerState(
initialHour = initialLocal.hour,
initialMinute = initialLocal.minute,
is24Hour = false,
is24Hour = AndroidDateFormat.is24HourFormat(context),
)
// On dismiss (Cancel or back-press), restore the picker states
@@ -69,15 +69,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.formatMediumDate
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDotNoDay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun EventSyncScreen(
@@ -750,6 +748,8 @@ private fun DateRangeFilterCard(
LaunchedEffect(effectiveSince) { onSinceChanged(effectiveSince) }
LaunchedEffect(effectiveUntil) { onUntilChanged(effectiveUntil) }
val context = LocalContext.current
Card(
modifier =
Modifier.fillMaxWidth(),
@@ -775,7 +775,7 @@ private fun DateRangeFilterCard(
if (effectiveSince == null) {
stringRes(R.string.event_sync_date_filter_all_time)
} else {
stringRes(R.string.event_sync_date_filter_since) + " " + formatEpochDate(sinceEpoch)
stringRes(R.string.event_sync_date_filter_since) + " " + formatMediumDate(sinceEpoch, context)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
@@ -785,7 +785,7 @@ private fun DateRangeFilterCard(
if (effectiveUntil == null) {
stringRes(R.string.event_sync_date_filter_now)
} else {
stringRes(R.string.event_sync_date_filter_until) + " " + formatEpochDate(untilEpoch)
stringRes(R.string.event_sync_date_filter_until) + " " + formatMediumDate(untilEpoch, context)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
@@ -810,11 +810,6 @@ private fun DateRangeFilterCard(
}
}
private fun formatEpochDate(epochSecs: Long): String {
val sdf = SimpleDateFormat("MMM d, yyyy", Locale.getDefault())
return sdf.format(Date(epochSecs * 1000))
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish
import android.text.format.DateFormat
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -58,6 +59,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@@ -72,6 +74,7 @@ import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.formatMediumDateTime
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog
@@ -85,12 +88,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableList
import java.text.SimpleDateFormat
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -131,11 +131,12 @@ fun RequestToVanishScreen(
val currentTime = Instant.ofEpochMilli(vanishDate * 1000).atZone(ZoneId.systemDefault()).toLocalDateTime()
val context = LocalContext.current
val timePickerState =
rememberTimePickerState(
initialHour = currentTime.hour,
initialMinute = currentTime.minute,
is24Hour = false,
is24Hour = DateFormat.is24HourFormat(context),
)
val relayOptions =
@@ -288,7 +289,7 @@ fun RequestToVanishScreen(
)
Spacer(Modifier.width(12.dp))
Text(
text = formatTimestamp(vanishDate),
text = formatMediumDateTime(vanishDate, context),
style = MaterialTheme.typography.bodyLarge,
)
}
@@ -461,11 +462,6 @@ private fun ConfirmVanishDialog(
)
}
private fun formatTimestamp(epochSeconds: Long): String {
val sdf = SimpleDateFormat("MMM dd, yyyy hh:mm a", Locale.getDefault())
return sdf.format(Date(epochSeconds * 1000))
}
@Preview
@Composable
fun RequestToVanishScreenPreview() {
@@ -47,6 +47,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -58,15 +59,13 @@ import com.vitorpamplona.amethyst.model.nip62Vanish.ComplianceStatus
import com.vitorpamplona.amethyst.model.nip62Vanish.VanishEventItem
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.formatMediumDateTime
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun VanishEventsScreen(
@@ -170,6 +169,7 @@ private fun VanishEventCard(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
),
) {
val context = LocalContext.current
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -182,7 +182,7 @@ private fun VanishEventCard(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = formatTimestamp(item.event.createdAt),
text = formatMediumDateTime(item.event.createdAt, context),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
@@ -355,8 +355,3 @@ private fun RelayComplianceRow(
}
}
}
private fun formatTimestamp(epochSeconds: Long): String {
val sdf = SimpleDateFormat("MMM dd, yyyy hh:mm a", Locale.getDefault())
return sdf.format(Date(epochSeconds * 1000))
}
@@ -60,6 +60,7 @@ 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.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
@@ -72,6 +73,7 @@ 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.commons.model.nip05DnsIdentifiers.namecoin.NamecoinSettings
import com.vitorpamplona.amethyst.ui.note.formatMediumDateTime
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend
@@ -79,8 +81,6 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcConf
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.RpcProbeResult
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ServerTestResult
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
@@ -497,10 +497,10 @@ private fun DiagnosticCard(
// Last test timestamp
if (lastTestTimestamp != null) {
val context = LocalContext.current
val formatted =
remember(lastTestTimestamp) {
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
.format(Date(lastTestTimestamp))
remember(lastTestTimestamp, context) {
formatMediumDateTime(lastTestTimestamp / 1000L, context)
}
val successCount = testResults.count { it.success }
val totalCount = testResults.size
@@ -51,6 +51,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.text.font.FontFamily
@@ -65,15 +66,13 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.formatMonthDayTime
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.datasource.OnchainZapsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.bitcoinColor
import java.text.NumberFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.absoluteValue
@OptIn(ExperimentalMaterial3Api::class)
@@ -299,14 +298,11 @@ private fun OnchainTransactionItem(
(if (isIncoming) "+" else "-") + fmt.format(amountSats)
}
val context = LocalContext.current
val dateText =
remember(view.tx.blockTime, view.tx.confirmations) {
remember(view.tx.blockTime, view.tx.confirmations, context) {
val ts = view.tx.blockTime
if (ts != null) {
SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()).format(Date(ts * 1000L))
} else {
""
}
if (ts != null) formatMonthDayTime(ts, context) else ""
}
val counterpartyPubkeyHex = view.counterpartyPubkeyHex()
@@ -50,6 +50,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -60,15 +61,13 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.formatMonthDayTime
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionType
import java.text.NumberFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -238,12 +237,10 @@ private fun TransactionItem(
(if (isIncoming) "+" else "-") + fmt.format(amountSats)
}
val context = LocalContext.current
val dateText =
remember(tx.created_at) {
tx.created_at?.let {
val sdf = SimpleDateFormat("MMM d, HH:mm", Locale.getDefault())
sdf.format(Date(it * 1000L))
} ?: ""
remember(tx.created_at, context) {
tx.created_at?.let { formatMonthDayTime(it, context) } ?: ""
}
val parsed = remember(tx.metadata) { tx.parsedMetadata() }
@@ -27,23 +27,39 @@ import java.util.Calendar
import java.util.Date
import java.util.Locale
private const val YEAR_DATE_FORMAT = "MMM dd, yyyy"
private const val MONTH_DATE_FORMAT = "MMM dd"
// Month + day without year — month name is textual so order is unambiguous across locales.
private const val MONTH_DATE_FORMAT = "MMM d"
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)
/**
* Per-thread cached [DateFormat] keyed off the current default [Locale].
*
* `DateFormat`/`SimpleDateFormat` are mutable and not thread-safe. These
* formatters can be read from UI threads (composition) and from background
* coroutines. `ThreadLocal` gives each thread its own instance no locks,
* no allocation per call, and we rebuild lazily on locale change.
*/
private class LocaleAwareFormatter(
private val build: (Locale) -> DateFormat,
) {
private val cache = ThreadLocal<Pair<Locale, DateFormat>>()
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)
fun get(): DateFormat {
val current = Locale.getDefault()
val cached = cache.get()
if (cached != null && cached.first == current) return cached.second
val fresh = build(current)
cache.set(current to fresh)
return fresh
}
}
// Locale-aware: en-US "May 28, 2026" · en-GB "28 May 2026" · de-DE "28.05.2026" · ja-JP "2026/05/28"
private val yearFormatter = LocaleAwareFormatter { DateFormat.getDateInstance(DateFormat.MEDIUM, it) }
private val monthFormatter = LocaleAwareFormatter { SimpleDateFormat(MONTH_DATE_FORMAT, it) }
// Locale-aware: en-US "2:32 PM" · en-GB "14:32" · de-DE "14:32"
private val timeOnlyFormatter = LocaleAwareFormatter { DateFormat.getTimeInstance(DateFormat.SHORT, it) }
/**
* Formats a Unix timestamp (seconds) as a human-readable time ago string.
* Returns strings like " • 5m", " • 2h", " • Dec 12"
@@ -64,13 +80,11 @@ fun timeAgo(
return when {
timeDifference > TimeUtils.ONE_YEAR -> {
updateFormattersIfNeeded()
prefix + yearFormatter.format(time * 1000)
prefix + yearFormatter.get().format(time * 1000)
}
timeDifference > TimeUtils.ONE_MONTH -> {
updateFormattersIfNeeded()
prefix + monthFormatter.format(time * 1000)
prefix + monthFormatter.get().format(time * 1000)
}
timeDifference > TimeUtils.ONE_DAY -> {
@@ -131,13 +145,11 @@ fun dateFormatter(
return when {
timeDifference > TimeUtils.ONE_YEAR -> {
updateFormattersIfNeeded()
yearFormatter.format(time * 1000)
yearFormatter.get().format(time * 1000)
}
timeDifference > TimeUtils.ONE_DAY -> {
updateFormattersIfNeeded()
monthFormatter.format(time * 1000)
monthFormatter.get().format(time * 1000)
}
else -> {
@@ -154,9 +166,9 @@ 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"
* - same day locale-aware short time (e.g. "14:32" / "2:32 PM")
* - same year month + day + short time (e.g. "May 28, 2:32 PM")
* - older locale-aware medium date (e.g. "May 28, 2026" / "28 May 2026" / "28.05.2026")
*/
fun timeAbsolute(
time: Long?,
@@ -167,8 +179,6 @@ fun timeAbsolute(
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 }
@@ -176,12 +186,12 @@ fun timeAbsolute(
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))
val timeOfDay = timeOnlyFormatter.get().format(Date(timeMs))
return when {
sameDay -> prefix + timeOfDay
sameYear -> prefix + monthFormatter.format(timeMs) + ", " + timeOfDay
else -> prefix + yearFormatter.format(timeMs)
sameYear -> prefix + monthFormatter.get().format(timeMs) + ", " + timeOfDay
else -> prefix + yearFormatter.get().format(timeMs)
}
}
@@ -97,12 +97,12 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.text.DateFormat
import java.util.Date
import java.util.Locale
private val articleDateFormat = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault())
// Locale-aware: en-US "May 28, 2026" · en-GB "28 May 2026" · de-DE "28.05.2026" · ja-JP "2026/05/28"
private val articleDateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault())
/**
* Parses a NIP-23 address tag in the format "30023:pubkey:d-tag".
@@ -352,11 +352,7 @@ fun ArticleReaderScreen(
val publishedAt =
article?.let { art ->
val ts = art.publishedAt() ?: art.createdAt
Instant
.ofEpochSecond(ts)
.atZone(ZoneId.systemDefault())
.toLocalDate()
.format(articleDateFormat)
articleDateFormat.format(Date(ts * 1000L))
}
// Author info from local cache
@@ -69,19 +69,14 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.text.DateFormat
import java.util.Date
import java.util.Locale
private val dateFormat = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault())
// Locale-aware: en-US "May 28, 2026" · en-GB "28 May 2026" · de-DE "28.05.2026" · ja-JP "2026/05/28"
private val dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault())
private fun formatDate(timestamp: Long): String =
Instant
.ofEpochSecond(timestamp)
.atZone(ZoneId.systemDefault())
.toLocalDate()
.format(dateFormat)
private fun formatDate(timestamp: Long): String = dateFormat.format(Date(timestamp * 1000L))
/**
* Card displaying long-form content (NIP-23) with title, summary, and image.
@@ -59,9 +59,9 @@ import com.vitorpamplona.amethyst.commons.model.highlights.HighlightData
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.text.DateFormat
import java.util.Date
import java.util.Locale
@Composable
fun MyHighlightsScreen(
@@ -238,8 +238,8 @@ private fun HighlightCard(
}
}
private fun formatTimestamp(epochSeconds: Long): String =
Instant
.ofEpochSecond(epochSeconds)
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
// Locale-aware date+time, e.g. en-US "May 28, 2026 2:32 PM" · de-DE "28.05.2026 14:32".
private val dateTimeFormat =
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT, Locale.getDefault())
private fun formatTimestamp(epochSeconds: Long): String = dateTimeFormat.format(Date(epochSeconds * 1000L))