feat: nickname card on the user profile, above the real display name

The profile header no longer replaces the big display name with the petname.
Instead, when the account nicknamed the user (or kept a private note about
them), an outlined card renders above it — petname, divider, private summary —
with the standard Lock private marker in its top-right corner, since both
fields live NIP-44 encrypted in the account's contact card. Tapping the card
opens the shared nickname editor. The profile's own display name stays fully
visible underneath. Feeds, chats and mentions keep rendering the petname
instead of the display name.

To carry the summary into the UI, the commons PetName holder generalizes to
Nickname(petName?, summary?, tags), built when either field exists — so a
note-only card (no petname) now shows on the profile too, while the name
override everywhere else keys strictly off petName.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
This commit is contained in:
Claude
2026-07-13 23:33:05 +00:00
parent a6da2ee69d
commit b76b37744e
11 changed files with 198 additions and 59 deletions
@@ -28,7 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.PetName
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.Nickname
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.NoteState
@@ -69,21 +69,21 @@ fun observeUserName(
}
/**
* The nickname (NIP-85 petname) the logged-in account gave this user through
* its own contact card, decrypted from the card's content, with the card's
* tags so `:shortcode:` custom emojis resolve. Null when the account never
* nicknamed this user. Per the spec, when present it should be rendered
* instead of the user's display name.
* The nickname (NIP-85 petname + private summary) the logged-in account gave
* this user through its own contact card, decrypted from the card's content,
* with the card's tags so `:shortcode:` custom emojis resolve. Null when the
* account never nicknamed this user. Per the spec, the petname should be
* rendered instead of the user's display name.
*/
@Composable
fun observeUserPetName(
fun observeUserNickname(
user: User,
accountViewModel: AccountViewModel,
): State<PetName?> {
): State<Nickname?> {
val contactCards = accountViewModel.account.contactCards
val flow = remember(user) { contactCards.petNameFlow(user) }
val flow = remember(user) { contactCards.nicknameFlow(user) }
return flow.collectAsStateWithLifecycle(remember(user) { contactCards.cachedPetName(user) })
return flow.collectAsStateWithLifecycle(remember(user) { contactCards.cachedNickname(user) })
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -60,7 +60,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPetName
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
@@ -299,15 +299,16 @@ fun RenderUserAsClickableText(
nav: INav,
) {
val userState by observeUserInfo(baseUser, accountViewModel)
val petName by observeUserPetName(baseUser, accountViewModel)
val nickname by observeUserNickname(baseUser, accountViewModel)
val petName = nickname?.petName
CreateClickableTextWithEmoji(
clickablePart = "@" + (petName?.petName ?: userState?.info?.bestName() ?: baseUser.pubkeyDisplayHex()),
clickablePart = "@" + (petName ?: userState?.info?.bestName() ?: baseUser.pubkeyDisplayHex()),
suffix = additionalChars?.ifBlank { null },
maxLines = 1,
route = remember(baseUser) { routeFor(baseUser) },
nav = nav,
tags = petName?.tags ?: userState?.tags ?: EmptyTagList,
tags = (if (petName != null) nickname?.tags else userState?.tags) ?: EmptyTagList,
)
}
@@ -105,7 +105,7 @@ import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPetName
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
@@ -1011,16 +1011,17 @@ private fun DisplayUserFromTag(
nav: INav,
) {
val meta by observeUserInfo(baseUser, accountViewModel)
val petName by observeUserPetName(baseUser, accountViewModel)
val nickname by observeUserNickname(baseUser, accountViewModel)
val petName = nickname?.petName
CrossfadeIfEnabled(targetState = meta, label = "DisplayUserFromTag", accountViewModel = accountViewModel) {
Row {
CreateClickableTextWithEmoji(
clickablePart = remember(meta, petName) { petName?.petName ?: it?.info?.bestName() ?: baseUser.pubkeyDisplayHex() },
clickablePart = remember(meta, petName) { petName ?: it?.info?.bestName() ?: baseUser.pubkeyDisplayHex() },
maxLines = 1,
route = remember(baseUser) { routeFor(baseUser) },
nav = nav,
tags = petName?.tags ?: it?.tags,
tags = if (petName != null) nickname?.tags else it?.tags,
)
}
}
@@ -40,7 +40,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPetName
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
import com.vitorpamplona.amethyst.service.tts.TextToSpeechHelper
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
@@ -106,14 +106,15 @@ fun UsernameDisplay(
accountViewModel: AccountViewModel,
) {
val userMetadata by observeUserInfo(baseUser, accountViewModel)
val petName by observeUserPetName(baseUser, accountViewModel)
val nickname by observeUserNickname(baseUser, accountViewModel)
CrossfadeIfEnabled(targetState = userMetadata, modifier = weight, label = "UsernameDisplay", accountViewModel = accountViewModel) {
// the account's own nickname for this user wins over the user's metadata;
// its custom emojis resolve against the contact card's tags, not the profile's
val name = petName?.petName ?: it?.info?.bestName()
val petName = nickname?.petName
val name = petName ?: it?.info?.bestName()
if (name != null) {
UserDisplay(name, petName?.tags ?: it?.tags, weight, fontWeight, textColor, textAlign)
UserDisplay(name, if (petName != null) nickname?.tags else it?.tags, weight, fontWeight, textColor, textAlign)
} else {
NPubDisplay(baseUser, weight, fontWeight, textColor, textAlign)
}
@@ -29,7 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPetName
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.FollowingIcon
@@ -59,14 +59,15 @@ private fun WatchAndDisplayUser(
nav: INav,
) {
val userState by observeUserInfo(author, accountViewModel)
val petName by observeUserPetName(author, accountViewModel)
val nickname by observeUserNickname(author, accountViewModel)
val petName = nickname?.petName
UserDisplayNameLayout(
picture = {
InnerUserPicture(
userHex = author.pubkeyHex,
userPicture = userState?.info?.picture,
userName = petName?.petName ?: userState?.info?.bestName(),
userName = petName ?: userState?.info?.bestName(),
size = Size20dp,
modifier = Modifier,
accountViewModel = accountViewModel,
@@ -83,8 +84,8 @@ private fun WatchAndDisplayUser(
name = {
if (userState != null) {
CreateTextWithEmoji(
text = petName?.petName ?: userState?.info?.bestName() ?: author.pubkeyDisplayHex(),
tags = petName?.tags ?: userState?.tags ?: EmptyTagList,
text = petName ?: userState?.info?.bestName() ?: author.pubkeyDisplayHex(),
tags = (if (petName != null) nickname?.tags else userState?.tags) ?: EmptyTagList,
maxLines = 1,
fontWeight = FontWeight.Bold,
)
@@ -56,7 +56,6 @@ import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPetName
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.util.LongPressCopyText
@@ -107,17 +106,17 @@ fun DrawAdditionalInfo(
val scope = rememberCoroutineScope()
val identities by externalIdentities.identities.collectAsStateWithLifecycle()
val petName by observeUserPetName(baseUser, accountViewModel)
// the nickname the account gave this user wins over the profile's own name;
// the "@name" line below keeps the real handle visible for disambiguation
val displayName = petName?.petName ?: user.info.bestName()
val displayName = user.info.bestName()
val ui = accountViewModel.settings.uiSettingsFlow
val showBadges by ui.showProfileBadges.collectAsStateWithLifecycle()
val showAppRecommendations by ui.showProfileAppRecommendations.collectAsStateWithLifecycle()
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = SpacedBy3dp) {
// the nickname the account gave this user, on top of (not replacing)
// the profile's own display name below
UserNicknameCard(baseUser, accountViewModel)
if (displayName != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -125,7 +124,7 @@ fun DrawAdditionalInfo(
) {
CreateTextWithEmoji(
text = displayName,
tags = petName?.tags ?: user.tags,
tags = user.tags,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
)
@@ -0,0 +1,125 @@
/*
* 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.profile.header
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.nip85TrustedAssertions.ui.EditNicknameDialog
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.placeholderText
/**
* The nickname (petname) and private note the account keeps for [baseUser],
* shown above the profile's own display name without replacing it. The lock
* marks it as private — both fields live NIP-44 encrypted in the account's
* contact card. Tapping the card opens the editor.
*/
@Composable
fun UserNicknameCard(
baseUser: User,
accountViewModel: AccountViewModel,
) {
val nickname by observeUserNickname(baseUser, accountViewModel)
val card = nickname ?: return
val isEditDialogOpen = remember { mutableStateOf(false) }
if (isEditDialogOpen.value) {
// keeps the account's selected emoji packs loaded for the : autocomplete
WatchAndLoadMyEmojiList(accountViewModel)
EditNicknameDialog(
user = baseUser,
contactCards = accountViewModel.account.contactCards,
onSave = { petName, summary -> accountViewModel.updateContactCardPetName(baseUser, petName, summary) },
onDismiss = { isEditDialogOpen.value = false },
)
}
OutlinedCard(
onClick = { isEditDialogOpen.value = true },
modifier = Modifier.fillMaxWidth().padding(top = 7.dp),
) {
Box(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
card.petName?.let {
CreateTextWithEmoji(
text = it,
tags = card.tags,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
}
if (card.petName != null && card.summary != null) {
HorizontalDivider(thickness = DividerThickness)
}
card.summary?.let {
CreateTextWithEmoji(
text = it,
tags = card.tags,
color = MaterialTheme.colorScheme.placeholderText,
fontSize = 14.sp,
)
}
}
Icon(
symbol = MaterialSymbols.Lock,
contentDescription = stringRes(R.string.nickname_private),
tint = MaterialTheme.colorScheme.placeholderText,
modifier =
Modifier
.align(Alignment.TopEnd)
.padding(top = 8.dp, end = 8.dp)
.size(14.dp),
)
}
}
}
+1
View File
@@ -3545,6 +3545,7 @@
<!-- Nicknames (NIP-85 contact cards); the dialog strings live in commons -->
<string name="edit_nickname">Edit nickname</string>
<string name="nickname_private">Only visible to you</string>
<!-- LAN cast (Chromecast) feature -->
<string name="cast_to_device">Cast to device</string>
@@ -44,20 +44,22 @@ class ContactCardDecryptionCache(
suspend fun summary(event: ContactCardEvent) = cachedPrivateCards.mergeTagList(event).summary()
/**
* The petname plus the card's full decrypted tag list, so renderers can
* resolve the NIP-30 `emoji` mappings stored alongside it.
* The decrypted petname and summary plus the card's full decrypted tag list,
* so renderers can resolve the NIP-30 `emoji` mappings stored alongside them.
*/
suspend fun petNameWithEmojis(event: ContactCardEvent): PetName? = cachedPrivateCards.mergeTagList(event).toPetName()
suspend fun nickname(event: ContactCardEvent): Nickname? = cachedPrivateCards.mergeTagList(event).toNickname()
/**
* Synchronous variant that only reads an already-decrypted card, for use as
* the immediate value of UI flows. Returns null until the suspend path has
* decrypted the card once.
*/
fun cachedPetNameWithEmojis(event: ContactCardEvent): PetName? = cachedPrivateCards.mergeTagListPrecached(event).toPetName()
fun cachedNickname(event: ContactCardEvent): Nickname? = cachedPrivateCards.mergeTagListPrecached(event).toNickname()
private fun TagArray.toPetName(): PetName? {
val name = petName() ?: return null
return PetName(name, toImmutableListOfLists())
private fun TagArray.toNickname(): Nickname? {
val name = petName()
val summary = summary()
if (name == null && summary == null) return null
return Nickname(name, summary, toImmutableListOfLists())
}
}
@@ -87,22 +87,23 @@ class ContactCardsState(
}
/**
* The petname the account gave [target], decrypted from the card's content,
* along with the card's tags so `:shortcode:` custom emojis resolve.
* The nickname (petname + private summary) the account gave [target],
* decrypted from the card's content, along with the card's tags so
* `:shortcode:` custom emojis resolve.
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun petNameFlow(target: User): Flow<PetName?> =
fun nicknameFlow(target: User): Flow<Nickname?> =
myCardFlow(target)
.mapLatest { card -> card?.let { decryptionCache.petNameWithEmojis(it) } }
.mapLatest { card -> card?.let { decryptionCache.nickname(it) } }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
/**
* Synchronously returns the petname for [target] when its card is already
* Synchronously returns the nickname for [target] when its card is already
* decrypted (or has none to decrypt). Cheap enough for initial values of UI
* flows: a map read plus the decryption cache lookup, no crypto.
*/
fun cachedPetName(target: User): PetName? {
fun cachedNickname(target: User): Nickname? {
val card =
target
.cardsOrNull()
@@ -110,7 +111,7 @@ class ContactCardsState(
?.value
?.get(accountUser)
?.event as? ContactCardEvent ?: return null
return decryptionCache.cachedPetNameWithEmojis(card)
return decryptionCache.cachedNickname(card)
}
/**
@@ -121,13 +122,13 @@ class ContactCardsState(
fun displayNameFlow(target: User): Flow<String> =
combine(
target.metadata().flow,
petNameFlow(target),
) { info, petName ->
petName?.petName ?: info?.info?.bestName() ?: target.pubkeyDisplayHex()
nicknameFlow(target),
) { info, nickname ->
nickname?.petName ?: info?.info?.bestName() ?: target.pubkeyDisplayHex()
}.distinctUntilChanged()
/** Synchronous first value for [displayNameFlow], from already-decrypted data. */
fun cachedDisplayName(target: User): String = cachedPetName(target)?.petName ?: target.toBestDisplayName()
fun cachedDisplayName(target: User): String = cachedNickname(target)?.petName ?: target.toBestDisplayName()
suspend fun petName(target: HexKey): String? = getCard(target)?.let { decryptionCache.petName(it) }
@@ -24,18 +24,25 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
/**
* The nickname the account gave a user, together with the card's decrypted tag
* list so renderers can resolve any NIP-30 `:shortcode:` custom emojis the
* petname uses (the `emoji` mappings live encrypted next to the petname).
* The decrypted private fields of the account's contact card about a user: the
* nickname (petname) and the private note (summary), plus the card's decrypted
* tag list so renderers can resolve any NIP-30 `:shortcode:` custom emojis they
* use (the `emoji` mappings live encrypted next to them). Only built when at
* least one of the two fields is present.
*/
@Immutable
class PetName(
val petName: String,
class Nickname(
val petName: String?,
val summary: String?,
val tags: ImmutableListOfLists<String>,
) {
// content equality so flow distinctUntilChanged() dedupes re-decryptions of
// the same card (ImmutableListOfLists itself compares by identity)
override fun equals(other: Any?): Boolean = other is PetName && petName == other.petName && tags.lists.contentDeepEquals(other.tags.lists)
override fun equals(other: Any?): Boolean =
other is Nickname &&
petName == other.petName &&
summary == other.summary &&
tags.lists.contentDeepEquals(other.tags.lists)
override fun hashCode(): Int = 31 * petName.hashCode() + tags.contentHash()
override fun hashCode(): Int = 31 * (31 * petName.hashCode() + summary.hashCode()) + tags.contentHash()
}