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:
co-authored by
The Daniel
parent
c22219550f
commit
858ea8a30b
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user