feat(ui): iOS-parity timestamps, NIP-05 badge, and follow badge (#624)

Three polish items to match iOS across post cards, notifications, quoted
notes, and avatars.

- Timestamps: compact day suffix ("1d".."6d") instead of the word
  "yesterday" at one day old, consistent with the existing s/m/h tiers;
  falls back to the absolute date only past a week. PostCard, GalleryCard,
  RichContent quoted-note preview, NotificationsScreen.
- NIP-05 badge: scalloped Icons.Default.Verified seal (points) instead of
  a plain circle; icon-only (no handle text) next to the username
  everywhere it appears — feed, threads, comments, galleries, and quoted
  notes. Handle text stays reserved for the profile screen.
- Follow badge: orange (primary) circle with a checkmark that flips with
  the color scheme — black in dark mode, white in light mode — derived
  from the active surface luminance so theme presets stay correct.

Co-authored-by: The Daniel <dmnyc@users.noreply.github.com>
This commit is contained in:
The Daniel 🖖
2026-07-23 10:46:30 -04:00
committed by GitHub
co-authored by The Daniel
parent c22219550f
commit 858ea8a30b
5 changed files with 94 additions and 43 deletions
@@ -324,21 +324,27 @@ fun GalleryCard(
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = displayName,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.clickable(onClick = onProfileClick)
)
profile?.nip05?.let { nip05 ->
Nip05Badge(
nip05 = nip05,
pubkey = event.pubkey,
nip05Repo = nip05Repo,
onClick = onProfileClick
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = displayName,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f, fill = false)
.clickable(onClick = onProfileClick)
)
profile?.nip05?.let { nip05 ->
Spacer(Modifier.width(4.dp))
Nip05Badge(
nip05 = nip05,
pubkey = event.pubkey,
nip05Repo = nip05Repo,
onClick = onProfileClick,
showHandle = false
)
}
}
}
Text(
@@ -935,7 +941,7 @@ private fun formatGalleryTimestamp(epoch: Long): String {
if (hours < 24) return "${hours}h"
val days = diff / (24 * 60 * 60 * 1000L)
if (days == 1L) return "yesterday"
if (days < 7) return "${days}d"
val date = Date(millis)
val cal = java.util.Calendar.getInstance()
@@ -64,7 +64,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.zIndex
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
@@ -346,14 +346,28 @@ fun PostCard(
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = displayName,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.clickable(onClick = onProfileClick)
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = displayName,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f, fill = false)
.clickable(onClick = onProfileClick)
)
profile?.nip05?.let { nip05 ->
Spacer(Modifier.width(4.dp))
Nip05Badge(
nip05 = nip05,
pubkey = event.pubkey,
nip05Repo = nip05Repo,
onClick = onProfileClick,
showHandle = false
)
}
}
// NIP-38: user status (hide on replies to reduce clutter)
val statusVersion by eventRepo?.statusVersion?.collectAsState() ?: remember { mutableIntStateOf(0) }
val userStatus = remember(statusVersion, event.pubkey) {
@@ -369,16 +383,6 @@ fun PostCard(
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
)
}
if (!Nip10.isReply(event)) {
profile?.nip05?.let { nip05 ->
Nip05Badge(
nip05 = nip05,
pubkey = event.pubkey,
nip05Repo = nip05Repo,
onClick = onProfileClick
)
}
}
}
if (isPrivate) {
Icon(
@@ -1295,7 +1299,7 @@ private val dateTimeYearFormat = SimpleDateFormat("MMM d, yyyy", Locale.US)
/**
* Format an epoch timestamp into a relative or absolute time string.
* Avoids Calendar allocations — uses simple arithmetic for "yesterday" check.
* Avoids Calendar allocations for the s/m/h/d tiers — simple arithmetic.
*/
private fun formatTimestamp(epoch: Long): String {
val now = System.currentTimeMillis()
@@ -1313,7 +1317,7 @@ private fun formatTimestamp(epoch: Long): String {
if (hours < 24) return "${hours}h"
val days = diff / (24 * 60 * 60 * 1000L)
if (days == 1L) return "yesterday"
if (days < 7) return "${days}d"
val date = Date(millis)
val cal = java.util.Calendar.getInstance()
@@ -1342,6 +1346,10 @@ internal fun Nip05Badge(
maxLines: Int = 1,
verifiedTint: Color = MaterialTheme.colorScheme.primary,
iconLeading: Boolean = false,
/** When false, render only the verification icon — no handle text. The handle itself
* is reserved for the profile screen; everywhere else (feed, threads, comments) just
* the badge icon appears next to the username. */
showHandle: Boolean = true,
modifier: Modifier = Modifier
) {
if (nip05.isBlank()) return
@@ -1370,10 +1378,29 @@ internal fun Nip05Badge(
}
)
) {
if (!showHandle) {
// Icon-only badge: appears once verification resolves. No retry icon here —
// a transient relay error shouldn't flag every row across the timeline.
when {
status == Nip05Status.VERIFIED -> Icon(
Icons.Default.Verified,
contentDescription = "Verified",
tint = verifiedTint,
modifier = Modifier.size(14.dp)
)
isImpersonator -> Icon(
Icons.Default.Cancel,
contentDescription = "Impersonator",
tint = Color.Red,
modifier = Modifier.size(14.dp)
)
}
return@Row
}
if (iconLeading) {
if (status == Nip05Status.VERIFIED) {
Icon(
Icons.Default.CheckCircle,
Icons.Default.Verified,
contentDescription = "Verified",
tint = verifiedTint,
modifier = Modifier.size(14.dp)
@@ -1409,7 +1436,7 @@ internal fun Nip05Badge(
if (status == Nip05Status.VERIFIED) {
Spacer(Modifier.width(4.dp))
Icon(
Icons.Default.CheckCircle,
Icons.Default.Verified,
contentDescription = "Verified",
tint = verifiedTint,
modifier = Modifier.size(14.dp)
@@ -28,6 +28,7 @@ import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
@@ -196,6 +197,10 @@ private fun BaseProfilePicture(
@Composable
private fun FollowBadge(size: Int, modifier: Modifier = Modifier) {
val badgeSize = (size * 0.3f).coerceIn(10f, 16f)
// iOS parity: the check flips with the color scheme — black on the
// orange circle in dark mode, white in light mode. Derive from the
// active surface so theme presets stay correct too.
val checkColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5f) Color.Black else Color.White
Box(
contentAlignment = Alignment.Center,
modifier = modifier
@@ -207,7 +212,7 @@ private fun FollowBadge(size: Int, modifier: Modifier = Modifier) {
Icon(
Icons.Default.Check,
contentDescription = "Following",
tint = MaterialTheme.colorScheme.onPrimary,
tint = checkColor,
modifier = Modifier.size((badgeSize * 0.65f).dp)
)
}
@@ -1316,15 +1316,28 @@ fun QuotedNote(
Row(verticalAlignment = Alignment.CenterVertically) {
ProfilePicture(url = profile?.picture, size = 34)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Text(
text = profile?.displayString
?: event.pubkey.toNpub().let { "${it.take(12)}...${it.takeLast(4)}" },
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
profile?.nip05?.let { nip05 ->
Spacer(Modifier.width(4.dp))
Nip05Badge(
nip05 = nip05,
pubkey = event.pubkey,
nip05Repo = noteActions?.nip05Repo,
showHandle = false
)
}
}
Text(
text = formatQuotedTimestamp(event.created_at),
@@ -2172,7 +2185,7 @@ private fun formatQuotedTimestamp(epoch: Long): String {
seconds < 60 -> "${seconds}s"
minutes < 60 -> "${minutes}m"
hours < 24 -> "${hours}h"
days == 1L -> "yesterday"
days < 7 -> "${days}d"
else -> java.text.SimpleDateFormat("MMM d", java.util.Locale.US).format(java.util.Date(epoch * 1000))
}
}
@@ -2256,7 +2256,7 @@ private fun formatNotifTimestamp(epoch: Long): String {
if (hours < 24) return "${hours}h"
val days = diff / (24 * 60 * 60 * 1000L)
if (days == 1L) return "yesterday"
if (days < 7) return "${days}d"
val date = Date(millis)
val cal = java.util.Calendar.getInstance()