From 8ee90907ca56df611c3c79d21b6d16d82755f9fd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 01:26:27 +0000 Subject: [PATCH 1/9] feat: nickname users via NIP-85 contact cards signed by the account key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds petnames/nicknames per https://github.com/nostr-protocol/nips/pull/761, reusing the kind:30382 contact card already used for WoT scores — a card only acts as a nickname when signed by the account's main key. Petname and summary are always stored in the card's NIP-44 encrypted content. quartz: - ContactCardEvent.updatePetNameAndSummary edits both fields in the encrypted private tags, strips stray public copies, preserves every other tag - TagArray.petName()/summary() parsers for decrypted tag lists + tests commons: - ContactCardDecryptionCache: LRU NIP-44 decrypt cache for own cards - ContactCardsState: account-scoped access to the account's own cards, petname flow per target user, create/update entry point amethyst: - Account.updateContactCardPetName publishes through the extended outbox relays (NIP-65 write + private outbox + local + broadcast) and the login subscription now downloads the account's own kind:30382s from its relays - petname renders instead of the display name in usernames, profile header, chats and @mentions (observeUserPetName) - Edit-nickname action + dialog on the profile actions menu Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY --- .../vitorpamplona/amethyst/model/Account.kt | 18 +++ .../FilterAccountInfoAndListsFromKey.kt | 17 ++ .../reqCommand/user/UserObservers.kt | 29 +++- .../amethyst/ui/components/ClickableRoute.kt | 4 +- .../amethyst/ui/components/RichTextViewer.kt | 4 +- .../amethyst/ui/note/UsernameDisplay.kt | 7 +- .../ui/screen/loggedIn/AccountViewModel.kt | 6 + .../loggedIn/chats/feed/DrawAuthorInfo.kt | 6 +- .../profile/header/DrawAdditionalInfo.kt | 7 +- .../profile/header/EditNicknameDialog.kt | 115 ++++++++++++++ .../profile/header/UserProfileDropDownMenu.kt | 22 +++ amethyst/src/main/res/values/strings.xml | 7 + .../ContactCardDecryptionCache.kt | 47 ++++++ .../ContactCardsState.kt | 127 +++++++++++++++ .../users/ContactCardEvent.kt | 40 +++++ .../users/TagArrayExt.kt | 30 ++++ .../ContactCardPetNameTest.kt | 147 ++++++++++++++++++ 17 files changed, 620 insertions(+), 13 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayExt.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 798aef0e4a..b108f69053 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -45,6 +45,8 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecr import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDecryptionCache +import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardDecryptionCache +import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult @@ -418,6 +420,9 @@ class Account( val trustProviderListDecryptionCache = TrustProviderListDecryptionCache(signer) val trustProviderList = TrustProviderListState(signer, cache, trustProviderListDecryptionCache, scope, settings) + val contactCardDecryptionCache = ContactCardDecryptionCache(signer) + val contactCards = ContactCardsState(signer, cache, contactCardDecryptionCache, scope) + val peopleListDecryptionCache = PeopleListDecryptionCache(signer) val blockPeopleList = BlockPeopleListState(signer, cache, peopleListDecryptionCache, scope) val peopleLists = PeopleListsState(signer, cache, peopleListDecryptionCache, scope) @@ -3771,6 +3776,19 @@ class Account( sendMyPublicAndPrivateOutbox(muteList.hideUser(pubkeyHex)) } + /** + * Nicknames a user by publishing the account's kind:30382 contact card about + * them, with the petname and summary NIP-44 encrypted in the content. `null` + * clears a field. Goes out through the account's extended outbox relays. + */ + suspend fun updateContactCardPetName( + pubkeyHex: HexKey, + petName: String?, + summary: String?, + ) { + sendMyPublicAndPrivateOutbox(contactCards.updatePetNameAndSummary(pubkeyHex, petName, summary)) + } + suspend fun showUser(pubkeyHex: HexKey) { sendMyPublicAndPrivateOutbox(blockPeopleList.showUser(pubkeyHex)) sendMyPublicAndPrivateOutbox(muteList.showUser(pubkeyHex)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 28b4f7b4f5..967bbd4f45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent @@ -93,6 +94,12 @@ val AccountInfoAndListsFromKeyKinds2 = NutzapInfoEvent.KIND, ) +// The account's own kind:30382 contact cards (nicknames/petnames for other +// users, NIP-44 encrypted). Addressable: one card per target user, so this is a +// collection rather than a single replaceable — kept out of the small-limit +// filters above and given its own filter with a larger limit. +val AccountContactCardKinds = listOf(ContactCardEvent.KIND) + val AmethystMetadataKinds = listOf(AppSpecificDataEvent.KIND) val AmethystMetadataTagMapFilter = mapOf("d" to listOf(APP_SPECIFIC_DATA_D_TAG)) @@ -124,6 +131,16 @@ fun filterAccountInfoAndListsFromKey( since = since, ), ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = AccountContactCardKinds, + authors = listOf(pubkey), + limit = 500, + since = since, + ), + ), RelayBasedFilter( relay = relay, filter = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 447b4f68d8..03f9099256 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -43,6 +43,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @@ -61,18 +62,34 @@ fun observeUserName( val flow = remember(user) { - user - .metadata() - .flow - .map { - it?.info?.bestName() ?: user.pubkeyDisplayHex() - }.distinctUntilChanged() + combine( + user.metadata().flow, + accountViewModel.account.contactCards.petNameFlow(user), + ) { info, petName -> + petName ?: info?.info?.bestName() ?: user.pubkeyDisplayHex() + }.distinctUntilChanged() } // Subscribe in the LocalCache for changes that arrive in the device return flow.collectAsStateWithLifecycle(user.toBestDisplayName()) } +/** + * The nickname (NIP-85 petname) the logged-in account gave this user through + * its own contact card, decrypted from the card's content. Null when the + * account never nicknamed this user. Per the spec, when present it should be + * rendered instead of the user's display name. + */ +@Composable +fun observeUserPetName( + user: User, + accountViewModel: AccountViewModel, +): State { + val flow = remember(user) { accountViewModel.account.contactCards.petNameFlow(user) } + + return flow.collectAsStateWithLifecycle(null) +} + @OptIn(ExperimentalCoroutinesApi::class) @Composable fun observeUserAboutMe( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index e87ede251a..d0cb60818b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -60,6 +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.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor @@ -298,9 +299,10 @@ fun RenderUserAsClickableText( nav: INav, ) { val userState by observeUserInfo(baseUser, accountViewModel) + val petName by observeUserPetName(baseUser, accountViewModel) CreateClickableTextWithEmoji( - clickablePart = "@" + (userState?.info?.bestName() ?: baseUser.pubkeyDisplayHex()), + clickablePart = "@" + (petName ?: userState?.info?.bestName() ?: baseUser.pubkeyDisplayHex()), suffix = additionalChars?.ifBlank { null }, maxLines = 1, route = remember(baseUser) { routeFor(baseUser) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 183a5395ea..f25a6d9049 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -105,6 +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.uploads.blossom.bud10.openBlossomUriAsIntent import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown @@ -1010,11 +1011,12 @@ private fun DisplayUserFromTag( nav: INav, ) { val meta by observeUserInfo(baseUser, accountViewModel) + val petName by observeUserPetName(baseUser, accountViewModel) CrossfadeIfEnabled(targetState = meta, label = "DisplayUserFromTag", accountViewModel = accountViewModel) { Row { CreateClickableTextWithEmoji( - clickablePart = remember(meta) { it?.info?.bestName() ?: baseUser.pubkeyDisplayHex() }, + clickablePart = remember(meta, petName) { petName ?: it?.info?.bestName() ?: baseUser.pubkeyDisplayHex() }, maxLines = 1, route = remember(baseUser) { routeFor(baseUser) }, nav = nav, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index e7f64cd167..f80ce17b7d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -40,6 +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.tts.TextToSpeechHelper import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji @@ -105,11 +106,13 @@ fun UsernameDisplay( accountViewModel: AccountViewModel, ) { val userMetadata by observeUserInfo(baseUser, accountViewModel) + val petName by observeUserPetName(baseUser, accountViewModel) CrossfadeIfEnabled(targetState = userMetadata, modifier = weight, label = "UsernameDisplay", accountViewModel = accountViewModel) { - val name = it?.info?.bestName() + // the account's own nickname for this user wins over the user's metadata + val name = petName ?: it?.info?.bestName() if (name != null) { - UserDisplay(name, it.tags, weight, fontWeight, textColor, textAlign) + UserDisplay(name, it?.tags, weight, fontWeight, textColor, textAlign) } else { NPubDisplay(baseUser, weight, fontWeight, textColor, textAlign) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 8e06823ba4..b554cf1a71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1704,6 +1704,12 @@ class AccountViewModel( fun hide(user: User) = launchSigner { account.hideUser(user.pubkeyHex) } + fun updateContactCardPetName( + user: User, + petName: String?, + summary: String?, + ) = launchSigner { account.updateContactCardPetName(user.pubkeyHex, petName, summary) } + fun hide(word: String) = launchSigner { account.hideWord(word) } fun showUser(pubkeyHex: String) = launchSigner { account.showUser(pubkeyHex) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt index 2488506058..b780b707be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt @@ -29,6 +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.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.FollowingIcon @@ -58,13 +59,14 @@ private fun WatchAndDisplayUser( nav: INav, ) { val userState by observeUserInfo(author, accountViewModel) + val petName by observeUserPetName(author, accountViewModel) UserDisplayNameLayout( picture = { InnerUserPicture( userHex = author.pubkeyHex, userPicture = userState?.info?.picture, - userName = userState?.info?.bestName(), + userName = petName ?: userState?.info?.bestName(), size = Size20dp, modifier = Modifier, accountViewModel = accountViewModel, @@ -81,7 +83,7 @@ private fun WatchAndDisplayUser( name = { if (userState != null) { CreateTextWithEmoji( - text = userState?.info?.bestName() ?: author.pubkeyDisplayHex(), + text = petName ?: userState?.info?.bestName() ?: author.pubkeyDisplayHex(), tags = userState?.tags ?: EmptyTagList, maxLines = 1, fontWeight = FontWeight.Bold, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index fd9a2d1d5f..4a785f0b19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -56,6 +56,7 @@ 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 @@ -106,7 +107,11 @@ fun DrawAdditionalInfo( val scope = rememberCoroutineScope() val identities by externalIdentities.identities.collectAsStateWithLifecycle() - val displayName = user.info.bestName() + 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 ?: user.info.bestName() val ui = accountViewModel.settings.uiSettingsFlow val showBadges by ui.showProfileBadges.collectAsStateWithLifecycle() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt new file mode 100644 index 0000000000..b2c9a6dfa6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt @@ -0,0 +1,115 @@ +/* + * 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.Column +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +/** + * Edits the nickname (petname) and private note (summary) the account keeps for + * [user] in its own kind:30382 contact card. Both fields are saved NIP-44 + * encrypted, so only this account can read them. Blank fields clear the value. + */ +@Composable +fun EditNicknameDialog( + user: User, + onDismiss: () -> Unit, + accountViewModel: AccountViewModel, +) { + val nickname = remember { mutableStateOf("") } + val summary = remember { mutableStateOf("") } + + // Prefill with the card's current encrypted values, if any. + LaunchedEffect(user) { + nickname.value = accountViewModel.account.contactCards.petName(user.pubkeyHex) ?: "" + summary.value = accountViewModel.account.contactCards.summary(user.pubkeyHex) ?: "" + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text(text = stringRes(R.string.nickname_dialog_title)) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = stringRes(R.string.nickname_dialog_explainer), + color = MaterialTheme.colorScheme.placeholderText, + ) + TextField( + value = nickname.value, + onValueChange = { nickname.value = it }, + singleLine = true, + label = { + Text(text = stringRes(R.string.nickname_label)) + }, + modifier = Modifier, + ) + TextField( + value = summary.value, + onValueChange = { summary.value = it }, + label = { + Text(text = stringRes(R.string.nickname_summary_label)) + }, + ) + } + }, + confirmButton = { + Button( + onClick = { + accountViewModel.updateContactCardPetName( + user = user, + petName = nickname.value.trim().ifBlank { null }, + summary = summary.value.trim().ifBlank { null }, + ) + onDismiss() + }, + ) { + Text(stringRes(R.string.save)) + } + }, + dismissButton = { + Button( + onClick = onDismiss, + ) { + Text(stringRes(R.string.cancel)) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt index 0fc0096a51..71e9ac8a38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header import android.content.Intent import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext @@ -45,6 +47,16 @@ fun UserProfileDropDownMenu( onDismiss: () -> Unit, accountViewModel: AccountViewModel, ) { + val isNicknameDialogOpen = remember { mutableStateOf(false) } + + if (isNicknameDialogOpen.value) { + EditNicknameDialog( + user = user, + onDismiss = { isNicknameDialogOpen.value = false }, + accountViewModel = accountViewModel, + ) + } + if (!popupExpanded) return M3ActionDialog( @@ -88,6 +100,16 @@ fun UserProfileDropDownMenu( // Moderation section (if not self) if (accountViewModel.userProfile() != user) { + M3ActionSection { + M3ActionRow( + icon = MaterialSymbols.Edit, + text = stringRes(R.string.edit_nickname), + ) { + isNicknameDialogOpen.value = true + onDismiss() + } + } + M3ActionSection { if (accountViewModel.account.isHidden(user)) { M3ActionRow( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 994f57d2a0..4b892769e2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3543,6 +3543,13 @@ Playback Auto + + Edit nickname + Nickname + Shown to you instead of this user\'s name, everywhere in the app. It\'s saved encrypted in your contact card: only you can read it. + Nickname + Private note about this user + Cast to device Stop casting diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt new file mode 100644 index 0000000000..e69a38f9b0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt @@ -0,0 +1,47 @@ +/* + * 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.commons.model.nip85TrustedAssertions + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.petName +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.summary + +/** + * Decrypts and caches the NIP-44 private tags of the account's own kind:30382 + * contact cards. Petname and summary always live in the encrypted part, so + * reading them requires the account's main key ([signer]); cards signed by any + * other key never decrypt here. + */ +class ContactCardDecryptionCache( + val signer: NostrSigner, +) { + val cachedPrivateCards = PrivateTagArrayEventCache(signer, cacheSize = 100) + + fun cachedPetName(event: ContactCardEvent) = cachedPrivateCards.mergeTagListPrecached(event).petName() + + fun cachedSummary(event: ContactCardEvent) = cachedPrivateCards.mergeTagListPrecached(event).summary() + + suspend fun petName(event: ContactCardEvent) = cachedPrivateCards.mergeTagList(event).petName() + + suspend fun summary(event: ContactCardEvent) = cachedPrivateCards.mergeTagList(event).summary() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt new file mode 100644 index 0000000000..bc6b0839cd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt @@ -0,0 +1,127 @@ +/* + * 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.commons.model.nip85TrustedAssertions + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest + +/** + * The account's own kind:30382 contact cards — one card per target user, signed + * by the account's main key. This is how the user nicknames other users: the + * petname and summary always live in the card's NIP-44 encrypted content. + * + * The same kind is used by trust providers for WoT scores; those cards are + * ignored here because everything is keyed on the card author being the + * account itself ([signer]'s pubkey). + */ +@Stable +class ContactCardsState( + val signer: NostrSigner, + val cache: ICacheProvider, + val decryptionCache: ContactCardDecryptionCache, + val scope: CoroutineScope, +) { + private val accountUser: User? by lazy { cache.getOrCreateUser(signer.pubKey) } + + fun createCardAddress(target: HexKey): Address = ContactCardEvent.createAddress(signer.pubKey, target) + + fun getCardNote(target: HexKey): AddressableNote = cache.getOrCreateAddressableNote(createCardAddress(target)) + + fun getCard(target: HexKey): ContactCardEvent? = getCardNote(target).event as? ContactCardEvent + + /** + * The account's own card about [target], as attached to the target user's + * [UserCardsCache] when the event is consumed. Reads from the existing + * received-cards map so displaying users without a card allocates nothing new. + */ + @OptIn(ExperimentalCoroutinesApi::class) + fun myCardFlow(target: User): Flow = + target + .cards() + .receivedCards + .map { it[accountUser] } + .distinctUntilChanged() + .flatMapLatest { note -> + note + ?.flow() + ?.metadata + ?.stateFlow + ?.map { it.note.event as? ContactCardEvent } + ?: flowOf(null) + } + + /** The petname the account gave [target], decrypted from the card's content. */ + @OptIn(ExperimentalCoroutinesApi::class) + fun petNameFlow(target: User): Flow = + myCardFlow(target) + .mapLatest { card -> card?.let { decryptionCache.petName(it) } } + .distinctUntilChanged() + .flowOn(Dispatchers.IO) + + suspend fun petName(target: HexKey): String? = getCard(target)?.let { decryptionCache.petName(it) } + + suspend fun summary(target: HexKey): String? = getCard(target)?.let { decryptionCache.summary(it) } + + /** + * Builds the new signed card for [target] with the given petname and summary + * (both stored NIP-44 encrypted; `null` clears the field), preserving every + * other tag of an existing card. The caller is responsible for publishing it. + */ + suspend fun updatePetNameAndSummary( + target: HexKey, + petName: String?, + summary: String?, + ): ContactCardEvent { + val existing = getCard(target) + return if (existing != null) { + ContactCardEvent.updatePetNameAndSummary( + earlierVersion = existing, + petName = petName, + summary = summary, + signer = signer, + ) + } else { + ContactCardEvent.create( + targetUser = target, + petName = petName, + summary = summary, + signer = signer, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt index 24b53cac48..dff8e70ba7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt @@ -26,11 +26,13 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.tagArray import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.remove import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ActiveHoursEndTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ActiveHoursStartTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.FirstCreatedAtTag @@ -144,5 +146,43 @@ class ContactCardEvent( val encryptedContent = PrivateTagsInContent.encryptNip44(privateTags, signer) return signer.sign(createdAt, KIND, publicTags, encryptedContent) } + + /** + * Replaces the petname and summary of an existing card, keeping every other + * public and private tag intact. Both fields always live in the NIP-44 + * encrypted content — any stray public copy is stripped. A `null` value + * removes the field from the card. + */ + suspend fun updatePetNameAndSummary( + earlierVersion: ContactCardEvent, + petName: String? = null, + summary: String? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): ContactCardEvent { + val privateTags = + earlierVersion.privateTags(signer) + ?: throw SignerExceptions.UnauthorizedDecryptionException() + + var newPrivateTags = + privateTags + .remove(arrayOf(PetNameTag.TAG_NAME)) + .remove(arrayOf(SummaryTag.TAG_NAME)) + + petName?.let { newPrivateTags = newPrivateTags.plus(PetNameTag.assemble(it)) } + summary?.let { newPrivateTags = newPrivateTags.plus(SummaryTag.assemble(it)) } + + val newPublicTags = + earlierVersion.tags + .remove(arrayOf(PetNameTag.TAG_NAME)) + .remove(arrayOf(SummaryTag.TAG_NAME)) + + return signer.sign( + createdAt, + KIND, + newPublicTags, + PrivateTagsInContent.encryptNip44(newPrivateTags, signer), + ) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayExt.kt new file mode 100644 index 0000000000..2af2ee5722 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayExt.kt @@ -0,0 +1,30 @@ +/* + * 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.quartz.nip85TrustedAssertions.users + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PetNameTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.SummaryTag + +fun TagArray.petName() = fastFirstNotNullOfOrNull(PetNameTag::parse) + +fun TagArray.summary() = fastFirstNotNullOfOrNull(SummaryTag::parse) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt new file mode 100644 index 0000000000..4617334369 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt @@ -0,0 +1,147 @@ +/* + * 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.quartz.experimental.nip85TrustedAssertions + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PetNameTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.SummaryTag +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ContactCardPetNameTest { + val signer = NostrSignerInternal(KeyPair()) + val targetUser = "e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411" + + private suspend fun ContactCardEvent.privatePetName() = privateTags(signer)?.firstNotNullOfOrNull(PetNameTag::parse) + + private suspend fun ContactCardEvent.privateSummary() = privateTags(signer)?.firstNotNullOfOrNull(SummaryTag::parse) + + @Test + fun createKeepsPetNameAndSummaryEncrypted() = + runTest { + val card = + ContactCardEvent.create( + targetUser = targetUser, + petName = "Bob from work", + summary = "Met at the conference", + signer = signer, + ) + + assertEquals(targetUser, card.aboutUser()) + + // never in the public tags + assertNull(card.petName()) + assertNull(card.summary()) + + // always in the encrypted content + assertEquals("Bob from work", card.privatePetName()) + assertEquals("Met at the conference", card.privateSummary()) + } + + @Test + fun updateReplacesPetNameAndKeepsOtherPrivateTags() = + runTest { + val card = + ContactCardEvent.create( + targetUser = targetUser, + petName = "Bob", + summary = "old summary", + signer = signer, + privateInitializer = { add(arrayOf("t", "friend")) }, + publicInitializer = { add(arrayOf("n", "follow")) }, + ) + + val updated = + ContactCardEvent.updatePetNameAndSummary( + earlierVersion = card, + petName = "Bobby", + summary = "new summary", + signer = signer, + ) + + assertEquals(targetUser, updated.aboutUser()) + assertEquals("Bobby", updated.privatePetName()) + assertEquals("new summary", updated.privateSummary()) + + // other tags survive on both sides + assertTrue(updated.privateTags(signer)!!.any { it.size > 1 && it[0] == "t" && it[1] == "friend" }) + assertTrue(updated.tags.any { it.size > 1 && it[0] == "n" && it[1] == "follow" }) + + // still nothing leaked publicly + assertNull(updated.petName()) + assertNull(updated.summary()) + } + + @Test + fun updateWithNullsClearsBothFields() = + runTest { + val card = + ContactCardEvent.create( + targetUser = targetUser, + petName = "Bob", + summary = "summary", + signer = signer, + ) + + val cleared = + ContactCardEvent.updatePetNameAndSummary( + earlierVersion = card, + petName = null, + summary = null, + signer = signer, + ) + + assertNull(cleared.privatePetName()) + assertNull(cleared.privateSummary()) + } + + @Test + fun updateStripsLegacyPublicCopies() = + runTest { + // a card that (incorrectly) carries public petname/summary tags + val card = + ContactCardEvent.create( + targetUser = targetUser, + signer = signer, + publicInitializer = { + add(PetNameTag.assemble("public bob")) + add(SummaryTag.assemble("public summary")) + }, + ) + assertEquals("public bob", card.petName()) + + val updated = + ContactCardEvent.updatePetNameAndSummary( + earlierVersion = card, + petName = "private bob", + signer = signer, + ) + + assertNull(updated.petName()) + assertNull(updated.summary()) + assertEquals("private bob", updated.privatePetName()) + } +} From ebeecd1ee7d8e7f4c01c3c1145ebab187a68607a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 01:39:59 +0000 Subject: [PATCH 2/9] feat: custom emojis in contact card petnames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nicknames can now use NIP-30 custom emojis: typing : in the nickname dialog autocompletes from the account's emoji packs, and the emoji mappings for any shortcode used are embedded in the card's NIP-44 encrypted content next to the petname — so even the emoji set stays private. Renderers resolve the petname's shortcodes against the card's decrypted tags instead of the profile's metadata tags. - quartz: updatePetNameAndSummary replaces the private emoji tag set wholesale and keeps it out of the public tags; round-trip test added - commons: PetName(name, tags) holder with content equality, decryption cache returns the merged decrypted tag list, EmojiPackState.findEmojiTags resolves :codes: against the selected packs - amethyst: Account embeds resolved emoji tags on save; all petname render sites pass the card tags to the WithEmoji composables; nickname dialog gets the : emoji autocomplete via EmojiSuggestionState Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY --- .../vitorpamplona/amethyst/model/Account.kt | 9 ++- .../reqCommand/user/UserObservers.kt | 12 +-- .../amethyst/ui/components/ClickableRoute.kt | 4 +- .../amethyst/ui/components/RichTextViewer.kt | 4 +- .../amethyst/ui/note/UsernameDisplay.kt | 7 +- .../loggedIn/chats/feed/DrawAuthorInfo.kt | 6 +- .../profile/header/DrawAdditionalInfo.kt | 4 +- .../profile/header/EditNicknameDialog.kt | 76 +++++++++++++++---- amethyst/src/main/res/values/strings.xml | 2 +- .../model/nip30CustomEmojis/EmojiPackState.kt | 17 +++++ .../ContactCardDecryptionCache.kt | 11 +++ .../ContactCardsState.kt | 21 +++-- .../model/nip85TrustedAssertions/PetName.kt | 41 ++++++++++ .../users/ContactCardEvent.kt | 16 +++- .../ContactCardPetNameTest.kt | 31 ++++++++ 15 files changed, 216 insertions(+), 45 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/PetName.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b108f69053..103170391b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -3778,15 +3778,18 @@ class Account( /** * Nicknames a user by publishing the account's kind:30382 contact card about - * them, with the petname and summary NIP-44 encrypted in the content. `null` - * clears a field. Goes out through the account's extended outbox relays. + * them, with the petname and summary NIP-44 encrypted in the content. Any + * `:shortcode:` from the account's emoji packs gets its NIP-30 emoji mapping + * embedded (also encrypted) so the nickname renders with custom emojis. + * `null` clears a field. Goes out through the account's extended outbox relays. */ suspend fun updateContactCardPetName( pubkeyHex: HexKey, petName: String?, summary: String?, ) { - sendMyPublicAndPrivateOutbox(contactCards.updatePetNameAndSummary(pubkeyHex, petName, summary)) + val emojis = emoji.findEmojiTags(listOfNotNull(petName, summary).joinToString(" ")) + sendMyPublicAndPrivateOutbox(contactCards.updatePetNameAndSummary(pubkeyHex, petName, summary, emojis)) } suspend fun showUser(pubkeyHex: HexKey) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 03f9099256..768fe0996b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -28,6 +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.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.NoteState @@ -66,7 +67,7 @@ fun observeUserName( user.metadata().flow, accountViewModel.account.contactCards.petNameFlow(user), ) { info, petName -> - petName ?: info?.info?.bestName() ?: user.pubkeyDisplayHex() + petName?.petName ?: info?.info?.bestName() ?: user.pubkeyDisplayHex() }.distinctUntilChanged() } @@ -76,15 +77,16 @@ 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. Null when the - * account never nicknamed this user. Per the spec, when present it should be - * rendered instead of the user's display name. + * 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. */ @Composable fun observeUserPetName( user: User, accountViewModel: AccountViewModel, -): State { +): State { val flow = remember(user) { accountViewModel.account.contactCards.petNameFlow(user) } return flow.collectAsStateWithLifecycle(null) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index d0cb60818b..b5a61c19bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -302,12 +302,12 @@ fun RenderUserAsClickableText( val petName by observeUserPetName(baseUser, accountViewModel) CreateClickableTextWithEmoji( - clickablePart = "@" + (petName ?: userState?.info?.bestName() ?: baseUser.pubkeyDisplayHex()), + clickablePart = "@" + (petName?.petName ?: userState?.info?.bestName() ?: baseUser.pubkeyDisplayHex()), suffix = additionalChars?.ifBlank { null }, maxLines = 1, route = remember(baseUser) { routeFor(baseUser) }, nav = nav, - tags = userState?.tags ?: EmptyTagList, + tags = petName?.tags ?: userState?.tags ?: EmptyTagList, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index f25a6d9049..56ce3b6411 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -1016,11 +1016,11 @@ private fun DisplayUserFromTag( CrossfadeIfEnabled(targetState = meta, label = "DisplayUserFromTag", accountViewModel = accountViewModel) { Row { CreateClickableTextWithEmoji( - clickablePart = remember(meta, petName) { petName ?: it?.info?.bestName() ?: baseUser.pubkeyDisplayHex() }, + clickablePart = remember(meta, petName) { petName?.petName ?: it?.info?.bestName() ?: baseUser.pubkeyDisplayHex() }, maxLines = 1, route = remember(baseUser) { routeFor(baseUser) }, nav = nav, - tags = it?.tags, + tags = petName?.tags ?: it?.tags, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index f80ce17b7d..c36934bbb5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -109,10 +109,11 @@ fun UsernameDisplay( val petName by observeUserPetName(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 - val name = petName ?: it?.info?.bestName() + // 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() if (name != null) { - UserDisplay(name, it?.tags, weight, fontWeight, textColor, textAlign) + UserDisplay(name, petName?.tags ?: it?.tags, weight, fontWeight, textColor, textAlign) } else { NPubDisplay(baseUser, weight, fontWeight, textColor, textAlign) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt index b780b707be..c05a352e98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt @@ -66,7 +66,7 @@ private fun WatchAndDisplayUser( InnerUserPicture( userHex = author.pubkeyHex, userPicture = userState?.info?.picture, - userName = petName ?: userState?.info?.bestName(), + userName = petName?.petName ?: userState?.info?.bestName(), size = Size20dp, modifier = Modifier, accountViewModel = accountViewModel, @@ -83,8 +83,8 @@ private fun WatchAndDisplayUser( name = { if (userState != null) { CreateTextWithEmoji( - text = petName ?: userState?.info?.bestName() ?: author.pubkeyDisplayHex(), - tags = userState?.tags ?: EmptyTagList, + text = petName?.petName ?: userState?.info?.bestName() ?: author.pubkeyDisplayHex(), + tags = petName?.tags ?: userState?.tags ?: EmptyTagList, maxLines = 1, fontWeight = FontWeight.Bold, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 4a785f0b19..33c17b342a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -111,7 +111,7 @@ fun DrawAdditionalInfo( // 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 ?: user.info.bestName() + val displayName = petName?.petName ?: user.info.bestName() val ui = accountViewModel.settings.uiSettingsFlow val showBadges by ui.showProfileBadges.collectAsStateWithLifecycle() @@ -125,7 +125,7 @@ fun DrawAdditionalInfo( ) { CreateTextWithEmoji( text = displayName, - tags = user.tags, + tags = petName?.tags ?: user.tags, fontWeight = FontWeight.Bold, fontSize = 22.sp, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt index b2c9a6dfa6..dd133fa887 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt @@ -22,11 +22,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf @@ -34,7 +37,13 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.text.currentWord +import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList +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.placeholderText @@ -43,6 +52,9 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText * Edits the nickname (petname) and private note (summary) the account keeps for * [user] in its own kind:30382 contact card. Both fields are saved NIP-44 * encrypted, so only this account can read them. Blank fields clear the value. + * + * Typing `:` offers the account's NIP-30 custom emojis; the mappings for any + * shortcode used are embedded (also encrypted) so the nickname renders with them. */ @Composable fun EditNicknameDialog( @@ -50,13 +62,30 @@ fun EditNicknameDialog( onDismiss: () -> Unit, accountViewModel: AccountViewModel, ) { - val nickname = remember { mutableStateOf("") } - val summary = remember { mutableStateOf("") } + val nickname = rememberTextFieldState() + val summary = rememberTextFieldState() + val emojiSuggestions = remember(accountViewModel) { EmojiSuggestionState(accountViewModel.account) } + // which field the emoji autocomplete should insert into + val emojiTarget = remember { mutableStateOf(null) } + + // keeps the account's selected emoji packs loaded while the dialog is open + WatchAndLoadMyEmojiList(accountViewModel) // Prefill with the card's current encrypted values, if any. LaunchedEffect(user) { - nickname.value = accountViewModel.account.contactCards.petName(user.pubkeyHex) ?: "" - summary.value = accountViewModel.account.contactCards.summary(user.pubkeyHex) ?: "" + accountViewModel.account.contactCards + .petName(user.pubkeyHex) + ?.let { nickname.setTextAndPlaceCursorAtEnd(it) } + accountViewModel.account.contactCards + .summary(user.pubkeyHex) + ?.let { summary.setTextAndPlaceCursorAtEnd(it) } + } + + fun watchEmojiIn(field: TextFieldState) { + emojiTarget.value = field + if (field.selection.collapsed) { + emojiSuggestions.processCurrentWord(field.currentWord()) + } } AlertDialog( @@ -72,22 +101,33 @@ fun EditNicknameDialog( text = stringRes(R.string.nickname_dialog_explainer), color = MaterialTheme.colorScheme.placeholderText, ) - TextField( - value = nickname.value, - onValueChange = { nickname.value = it }, + ThinPaddingTextField( + state = nickname, + onTextChanged = { watchEmojiIn(nickname) }, singleLine = true, label = { Text(text = stringRes(R.string.nickname_label)) }, - modifier = Modifier, ) - TextField( - value = summary.value, - onValueChange = { summary.value = it }, + ThinPaddingTextField( + state = summary, + onTextChanged = { watchEmojiIn(summary) }, label = { Text(text = stringRes(R.string.nickname_summary_label)) }, ) + ShowEmojiSuggestionList( + emojiSuggestions, + onSelect = { + emojiTarget.value?.replaceCurrentWord(":${it.code}:") + emojiSuggestions.reset() + }, + onFullSize = { + emojiTarget.value?.replaceCurrentWord(":${it.code}:") + emojiSuggestions.reset() + }, + modifier = Modifier.heightIn(max = 200.dp), + ) } }, confirmButton = { @@ -95,8 +135,16 @@ fun EditNicknameDialog( onClick = { accountViewModel.updateContactCardPetName( user = user, - petName = nickname.value.trim().ifBlank { null }, - summary = summary.value.trim().ifBlank { null }, + petName = + nickname.text + .toString() + .trim() + .ifBlank { null }, + summary = + summary.text + .toString() + .trim() + .ifBlank { null }, ) onDismiss() }, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 4b892769e2..c6f381f16e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3546,7 +3546,7 @@ Edit nickname Nickname - Shown to you instead of this user\'s name, everywhere in the app. It\'s saved encrypted in your contact card: only you can read it. + Shown to you instead of this user\'s name, everywhere in the app. It\'s saved encrypted in your contact card: only you can read it. Type : to use your custom emojis. Nickname Private note about this user diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt index f8f77d36c9..3aa74c34f4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses +import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent @@ -125,6 +126,22 @@ class EmojiPackState( emptyList(), ) + /** + * Resolves every `:shortcode:` in [message] against the account's selected + * emoji packs, returning the NIP-30 `emoji` tags an event needs to carry for + * the codes to render. Unknown codes are simply skipped. + */ + fun findEmojiTags(message: String): List { + val myEmojiSet = myEmojis.value + if (myEmojiSet.isEmpty()) return emptyList() + return CustomEmoji + .findAllEmojiCodes(message) + .distinct() + .mapNotNull { code -> + myEmojiSet.firstOrNull { it.code == code }?.let { EmojiUrlTag(it.code, it.link) } + } + } + suspend fun addEmojiPack(emojiPack: Note): EmojiPackSelectionEvent { val emojiPackEvent = emojiPack.event if (emojiPackEvent !is EmojiPackEvent) throw IllegalArgumentException("Note is not an EmojiPackEvent; cannot add to emoji list.") diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt index e69a38f9b0..42b9142a8e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent @@ -44,4 +45,14 @@ class ContactCardDecryptionCache( suspend fun petName(event: ContactCardEvent) = cachedPrivateCards.mergeTagList(event).petName() 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. + */ + suspend fun petNameWithEmojis(event: ContactCardEvent): PetName? { + val merged = cachedPrivateCards.mergeTagList(event) + val name = merged.petName() ?: return null + return PetName(name, merged.toImmutableListOfLists()) + } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt index bc6b0839cd..1c15ff9222 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt @@ -27,6 +27,8 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -85,11 +87,14 @@ class ContactCardsState( ?: flowOf(null) } - /** The petname the account gave [target], decrypted from the card's content. */ + /** + * The petname 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 = + fun petNameFlow(target: User): Flow = myCardFlow(target) - .mapLatest { card -> card?.let { decryptionCache.petName(it) } } + .mapLatest { card -> card?.let { decryptionCache.petNameWithEmojis(it) } } .distinctUntilChanged() .flowOn(Dispatchers.IO) @@ -98,14 +103,16 @@ class ContactCardsState( suspend fun summary(target: HexKey): String? = getCard(target)?.let { decryptionCache.summary(it) } /** - * Builds the new signed card for [target] with the given petname and summary - * (both stored NIP-44 encrypted; `null` clears the field), preserving every - * other tag of an existing card. The caller is responsible for publishing it. + * Builds the new signed card for [target] with the given petname, summary and + * the NIP-30 emoji mappings their shortcodes use (all stored NIP-44 encrypted; + * `null` clears a field), preserving every other tag of an existing card. The + * caller is responsible for publishing it. */ suspend fun updatePetNameAndSummary( target: HexKey, petName: String?, summary: String?, + emojis: List = emptyList(), ): ContactCardEvent { val existing = getCard(target) return if (existing != null) { @@ -113,6 +120,7 @@ class ContactCardsState( earlierVersion = existing, petName = petName, summary = summary, + emojis = emojis, signer = signer, ) } else { @@ -121,6 +129,7 @@ class ContactCardsState( petName = petName, summary = summary, signer = signer, + privateInitializer = { emojis(emojis) }, ) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/PetName.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/PetName.kt new file mode 100644 index 0000000000..1cabf8e564 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/PetName.kt @@ -0,0 +1,41 @@ +/* + * 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.commons.model.nip85TrustedAssertions + +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). + */ +@Immutable +class PetName( + val petName: String, + val tags: ImmutableListOfLists, +) { + // 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 hashCode(): Int = 31 * petName.hashCode() + tags.contentHash() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt index dff8e70ba7..14b5f5711f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent @@ -148,15 +149,18 @@ class ContactCardEvent( } /** - * Replaces the petname and summary of an existing card, keeping every other - * public and private tag intact. Both fields always live in the NIP-44 - * encrypted content — any stray public copy is stripped. A `null` value - * removes the field from the card. + * Replaces the petname, summary and their NIP-30 custom emoji mappings on an + * existing card, keeping every other public and private tag intact. All of + * them always live in the NIP-44 encrypted content — any stray public + * petname/summary copy is stripped. A `null` value removes the field; the + * private `emoji` tag set is replaced wholesale since it only exists to + * render the petname/summary shortcodes. */ suspend fun updatePetNameAndSummary( earlierVersion: ContactCardEvent, petName: String? = null, summary: String? = null, + emojis: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), ): ContactCardEvent { @@ -168,9 +172,13 @@ class ContactCardEvent( privateTags .remove(arrayOf(PetNameTag.TAG_NAME)) .remove(arrayOf(SummaryTag.TAG_NAME)) + .remove(arrayOf(EmojiUrlTag.TAG_NAME)) petName?.let { newPrivateTags = newPrivateTags.plus(PetNameTag.assemble(it)) } summary?.let { newPrivateTags = newPrivateTags.plus(SummaryTag.assemble(it)) } + if (emojis.isNotEmpty()) { + newPrivateTags = newPrivateTags.plus(emojis.map { it.toTagArray() }) + } val newPublicTags = earlierVersion.tags diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt index 4617334369..5146223894 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.experimental.nip85TrustedAssertions import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PetNameTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.SummaryTag @@ -118,6 +120,35 @@ class ContactCardPetNameTest { assertNull(cleared.privateSummary()) } + @Test + fun updateReplacesCustomEmojiMappings() = + runTest { + val oldEmoji = EmojiUrlTag("wave", "https://old.example/wave.png") + val newEmoji = EmojiUrlTag("soapbox", "https://new.example/soapbox.png") + + val card = + ContactCardEvent.create( + targetUser = targetUser, + petName = "Bob :wave:", + signer = signer, + privateInitializer = { emojis(listOf(oldEmoji)) }, + ) + assertEquals(listOf(oldEmoji), card.privateTags(signer)!!.mapNotNull(EmojiUrlTag::parse)) + + val updated = + ContactCardEvent.updatePetNameAndSummary( + earlierVersion = card, + petName = "Bob :soapbox:", + emojis = listOf(newEmoji), + signer = signer, + ) + + assertEquals("Bob :soapbox:", updated.privatePetName()) + // the emoji set is replaced wholesale, still encrypted + assertEquals(listOf(newEmoji), updated.privateTags(signer)!!.mapNotNull(EmojiUrlTag::parse)) + assertTrue(updated.tags.none { it.size > 0 && it[0] == EmojiUrlTag.TAG_NAME }) + } + @Test fun updateStripsLegacyPublicCopies() = runTest { From 184f0bfde7a3948d71d92d3a4e515f4bf771b529 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 12:28:23 +0000 Subject: [PATCH 3/9] refactor: align ContactCardEvent with the nip88Polls class structure Replicates the PollEvent layout: all tag parsing moves to TagArray extensions in TagArrayExt.kt with the event accessors delegating to them, builder extensions use addUnique for single-instance tags, and construction goes through template-returning builders instead of methods that sign internally. - build() returns an EventTemplate (the signer is only used to NIP-44 encrypt the private tags, matching TrustProviderListEvent); create() remains as the signer.sign(build(...)) convenience and now takes the emoji list directly - updatePetNameAndSummary() returns an unsigned EventTemplate; callers sign it (ContactCardsState and tests updated) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY --- .../ContactCardsState.kt | 17 +-- .../users/ContactCardEvent.kt | 117 +++++++++--------- .../users/TagArrayBuilderExt.kt | 36 +++--- .../users/TagArrayExt.kt | 51 ++++++++ .../ContactCardPetNameTest.kt | 49 ++++---- 5 files changed, 167 insertions(+), 103 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt index 1c15ff9222..1a651c27eb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt @@ -28,7 +28,6 @@ import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag -import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -116,20 +115,22 @@ class ContactCardsState( ): ContactCardEvent { val existing = getCard(target) return if (existing != null) { - ContactCardEvent.updatePetNameAndSummary( - earlierVersion = existing, - petName = petName, - summary = summary, - emojis = emojis, - signer = signer, + signer.sign( + ContactCardEvent.updatePetNameAndSummary( + earlierVersion = existing, + petName = petName, + summary = summary, + emojis = emojis, + signer = signer, + ), ) } else { ContactCardEvent.create( targetUser = target, petName = petName, summary = summary, + emojis = emojis, signer = signer, - privateInitializer = { emojis(emojis) }, ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt index 14b5f5711f..6f276a3716 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt @@ -25,34 +25,20 @@ import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.tagArray +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent import com.vitorpamplona.quartz.nip51Lists.remove -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ActiveHoursEndTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ActiveHoursStartTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.FirstCreatedAtTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.FollowerCountTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PetNameTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PostCountTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.RankTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReactionsCountTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReplyCountTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReportsCountReceivedTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReportsCountSentTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.SummaryTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.TopicTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAmountReceivedTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAmountSentTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAvgAmountDayReceivedTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAvgAmountDaySentTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapCountReceivedTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapCountSentTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -71,43 +57,43 @@ class ContactCardEvent( fun aboutUser() = tags.dTag() - fun rank() = tags.firstNotNullOfOrNull(RankTag::parse) + fun rank() = tags.rank() - fun followerCount() = tags.firstNotNullOfOrNull(FollowerCountTag::parse) + fun followerCount() = tags.followerCount() - fun firstCreatedAt() = tags.firstNotNullOfOrNull(FirstCreatedAtTag::parse) + fun firstCreatedAt() = tags.firstCreatedAt() - fun postCount() = tags.firstNotNullOfOrNull(PostCountTag::parse) + fun postCount() = tags.postCount() - fun replyCount() = tags.firstNotNullOfOrNull(ReplyCountTag::parse) + fun replyCount() = tags.replyCount() - fun reactionsCount() = tags.firstNotNullOfOrNull(ReactionsCountTag::parse) + fun reactionsCount() = tags.reactionsCount() - fun zapAmountReceived() = tags.firstNotNullOfOrNull(ZapAmountReceivedTag::parse) + fun zapAmountReceived() = tags.zapAmountReceived() - fun zapAmountSent() = tags.firstNotNullOfOrNull(ZapAmountSentTag::parse) + fun zapAmountSent() = tags.zapAmountSent() - fun zapCountReceived() = tags.firstNotNullOfOrNull(ZapCountReceivedTag::parse) + fun zapCountReceived() = tags.zapCountReceived() - fun zapCountSent() = tags.firstNotNullOfOrNull(ZapCountSentTag::parse) + fun zapCountSent() = tags.zapCountSent() - fun zapAvgAmountDayReceived() = tags.firstNotNullOfOrNull(ZapAvgAmountDayReceivedTag::parse) + fun zapAvgAmountDayReceived() = tags.zapAvgAmountDayReceived() - fun zapAvgAmountDaySent() = tags.firstNotNullOfOrNull(ZapAvgAmountDaySentTag::parse) + fun zapAvgAmountDaySent() = tags.zapAvgAmountDaySent() - fun reportsCountReceived() = tags.firstNotNullOfOrNull(ReportsCountReceivedTag::parse) + fun reportsCountReceived() = tags.reportsCountReceived() - fun reportsCountSent() = tags.firstNotNullOfOrNull(ReportsCountSentTag::parse) + fun reportsCountSent() = tags.reportsCountSent() - fun topics() = tags.mapNotNull(TopicTag::parse) + fun topics() = tags.topics() - fun activeHoursStart() = tags.firstNotNullOfOrNull(ActiveHoursStartTag::parse) + fun activeHoursStart() = tags.activeHoursStart() - fun activeHoursEnd() = tags.firstNotNullOfOrNull(ActiveHoursEndTag::parse) + fun activeHoursEnd() = tags.activeHoursEnd() - fun petName() = tags.firstNotNullOfOrNull(PetNameTag::parse) + fun petName() = tags.petName() - fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) + fun summary() = tags.summary() companion object { const val KIND = 30382 @@ -126,35 +112,54 @@ class ContactCardEvent( targetUser: HexKey, petName: String? = null, summary: String? = null, + emojis: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), publicInitializer: TagArrayBuilder.() -> Unit = {}, privateInitializer: TagArrayBuilder.() -> Unit = {}, - ): ContactCardEvent { - val publicTags = - tagArray { - dTag(targetUser) - publicInitializer() - } + ): ContactCardEvent = signer.sign(build(targetUser, petName, summary, emojis, signer, createdAt, publicInitializer, privateInitializer)) + /** + * Unsigned template for a new card about [targetUser]. The petname, summary + * and the NIP-30 emoji mappings their shortcodes use always go in the NIP-44 + * encrypted content ([signer] only encrypts here; the caller signs). + */ + suspend fun build( + targetUser: HexKey, + petName: String? = null, + summary: String? = null, + emojis: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + publicInitializer: TagArrayBuilder.() -> Unit = {}, + privateInitializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { val privateTags = tagArray { petName?.let { petName(it) } summary?.let { summary(it) } + emojis(emojis) privateInitializer() } - val encryptedContent = PrivateTagsInContent.encryptNip44(privateTags, signer) - return signer.sign(createdAt, KIND, publicTags, encryptedContent) + return eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateTags, signer), + createdAt = createdAt, + ) { + dTag(targetUser) + publicInitializer() + } } /** - * Replaces the petname, summary and their NIP-30 custom emoji mappings on an - * existing card, keeping every other public and private tag intact. All of - * them always live in the NIP-44 encrypted content — any stray public - * petname/summary copy is stripped. A `null` value removes the field; the - * private `emoji` tag set is replaced wholesale since it only exists to - * render the petname/summary shortcodes. + * Unsigned template that replaces the petname, summary and their NIP-30 + * custom emoji mappings on an existing card, keeping every other public and + * private tag intact. All of them always live in the NIP-44 encrypted + * content — any stray public petname/summary copy is stripped. A `null` + * value removes the field; the private `emoji` tag set is replaced + * wholesale since it only exists to render the petname/summary shortcodes. + * [signer] only decrypts/encrypts here; the caller signs the template. */ suspend fun updatePetNameAndSummary( earlierVersion: ContactCardEvent, @@ -163,7 +168,7 @@ class ContactCardEvent( emojis: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): ContactCardEvent { + ): EventTemplate { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() @@ -185,11 +190,11 @@ class ContactCardEvent( .remove(arrayOf(PetNameTag.TAG_NAME)) .remove(arrayOf(SummaryTag.TAG_NAME)) - return signer.sign( - createdAt, - KIND, - newPublicTags, - PrivateTagsInContent.encryptNip44(newPrivateTags, signer), + return EventTemplate( + createdAt = createdAt, + kind = KIND, + tags = newPublicTags, + content = PrivateTagsInContent.encryptNip44(newPrivateTags, signer), ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayBuilderExt.kt index 5c050c482d..88d87d7359 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayBuilderExt.kt @@ -41,40 +41,40 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAvgAmountDa import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapCountReceivedTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapCountSentTag -fun TagArrayBuilder.rank(rank: Int) = add(RankTag.assemble(rank)) +fun TagArrayBuilder.rank(rank: Int) = addUnique(RankTag.assemble(rank)) -fun TagArrayBuilder.followers(count: Int) = add(FollowerCountTag.assemble(count)) +fun TagArrayBuilder.followers(count: Int) = addUnique(FollowerCountTag.assemble(count)) -fun TagArrayBuilder.firstCreatedAt(timestamp: Long) = add(FirstCreatedAtTag.assemble(timestamp)) +fun TagArrayBuilder.firstCreatedAt(timestamp: Long) = addUnique(FirstCreatedAtTag.assemble(timestamp)) -fun TagArrayBuilder.postCount(count: Int) = add(PostCountTag.assemble(count)) +fun TagArrayBuilder.postCount(count: Int) = addUnique(PostCountTag.assemble(count)) -fun TagArrayBuilder.replyCount(count: Int) = add(ReplyCountTag.assemble(count)) +fun TagArrayBuilder.replyCount(count: Int) = addUnique(ReplyCountTag.assemble(count)) -fun TagArrayBuilder.reactionsCount(count: Int) = add(ReactionsCountTag.assemble(count)) +fun TagArrayBuilder.reactionsCount(count: Int) = addUnique(ReactionsCountTag.assemble(count)) -fun TagArrayBuilder.zapAmountReceived(sats: Long) = add(ZapAmountReceivedTag.assemble(sats)) +fun TagArrayBuilder.zapAmountReceived(sats: Long) = addUnique(ZapAmountReceivedTag.assemble(sats)) -fun TagArrayBuilder.zapAmountSent(sats: Long) = add(ZapAmountSentTag.assemble(sats)) +fun TagArrayBuilder.zapAmountSent(sats: Long) = addUnique(ZapAmountSentTag.assemble(sats)) -fun TagArrayBuilder.zapCountReceived(count: Int) = add(ZapCountReceivedTag.assemble(count)) +fun TagArrayBuilder.zapCountReceived(count: Int) = addUnique(ZapCountReceivedTag.assemble(count)) -fun TagArrayBuilder.zapCountSent(count: Int) = add(ZapCountSentTag.assemble(count)) +fun TagArrayBuilder.zapCountSent(count: Int) = addUnique(ZapCountSentTag.assemble(count)) -fun TagArrayBuilder.zapAvgAmountDayReceived(sats: Long) = add(ZapAvgAmountDayReceivedTag.assemble(sats)) +fun TagArrayBuilder.zapAvgAmountDayReceived(sats: Long) = addUnique(ZapAvgAmountDayReceivedTag.assemble(sats)) -fun TagArrayBuilder.zapAvgAmountDaySent(sats: Long) = add(ZapAvgAmountDaySentTag.assemble(sats)) +fun TagArrayBuilder.zapAvgAmountDaySent(sats: Long) = addUnique(ZapAvgAmountDaySentTag.assemble(sats)) -fun TagArrayBuilder.reportsCountReceived(count: Int) = add(ReportsCountReceivedTag.assemble(count)) +fun TagArrayBuilder.reportsCountReceived(count: Int) = addUnique(ReportsCountReceivedTag.assemble(count)) -fun TagArrayBuilder.reportsCountSent(count: Int) = add(ReportsCountSentTag.assemble(count)) +fun TagArrayBuilder.reportsCountSent(count: Int) = addUnique(ReportsCountSentTag.assemble(count)) fun TagArrayBuilder.topic(topic: String) = add(TopicTag.assemble(topic)) -fun TagArrayBuilder.activeHoursStart(hour: Int) = add(ActiveHoursStartTag.assemble(hour)) +fun TagArrayBuilder.activeHoursStart(hour: Int) = addUnique(ActiveHoursStartTag.assemble(hour)) -fun TagArrayBuilder.activeHoursEnd(hour: Int) = add(ActiveHoursEndTag.assemble(hour)) +fun TagArrayBuilder.activeHoursEnd(hour: Int) = addUnique(ActiveHoursEndTag.assemble(hour)) -fun TagArrayBuilder.petName(name: String) = add(PetNameTag.assemble(name)) +fun TagArrayBuilder.petName(name: String) = addUnique(PetNameTag.assemble(name)) -fun TagArrayBuilder.summary(summary: String) = add(SummaryTag.assemble(summary)) +fun TagArrayBuilder.summary(summary: String) = addUnique(SummaryTag.assemble(summary)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayExt.kt index 2af2ee5722..fad3a43cc8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayExt.kt @@ -22,8 +22,59 @@ package com.vitorpamplona.quartz.nip85TrustedAssertions.users import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ActiveHoursEndTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ActiveHoursStartTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.FirstCreatedAtTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.FollowerCountTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PetNameTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PostCountTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.RankTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReactionsCountTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReplyCountTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReportsCountReceivedTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReportsCountSentTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.SummaryTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.TopicTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAmountReceivedTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAmountSentTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAvgAmountDayReceivedTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAvgAmountDaySentTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapCountReceivedTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapCountSentTag + +fun TagArray.rank() = fastFirstNotNullOfOrNull(RankTag::parse) + +fun TagArray.followerCount() = fastFirstNotNullOfOrNull(FollowerCountTag::parse) + +fun TagArray.firstCreatedAt() = fastFirstNotNullOfOrNull(FirstCreatedAtTag::parse) + +fun TagArray.postCount() = fastFirstNotNullOfOrNull(PostCountTag::parse) + +fun TagArray.replyCount() = fastFirstNotNullOfOrNull(ReplyCountTag::parse) + +fun TagArray.reactionsCount() = fastFirstNotNullOfOrNull(ReactionsCountTag::parse) + +fun TagArray.zapAmountReceived() = fastFirstNotNullOfOrNull(ZapAmountReceivedTag::parse) + +fun TagArray.zapAmountSent() = fastFirstNotNullOfOrNull(ZapAmountSentTag::parse) + +fun TagArray.zapCountReceived() = fastFirstNotNullOfOrNull(ZapCountReceivedTag::parse) + +fun TagArray.zapCountSent() = fastFirstNotNullOfOrNull(ZapCountSentTag::parse) + +fun TagArray.zapAvgAmountDayReceived() = fastFirstNotNullOfOrNull(ZapAvgAmountDayReceivedTag::parse) + +fun TagArray.zapAvgAmountDaySent() = fastFirstNotNullOfOrNull(ZapAvgAmountDaySentTag::parse) + +fun TagArray.reportsCountReceived() = fastFirstNotNullOfOrNull(ReportsCountReceivedTag::parse) + +fun TagArray.reportsCountSent() = fastFirstNotNullOfOrNull(ReportsCountSentTag::parse) + +fun TagArray.topics() = mapNotNull(TopicTag::parse) + +fun TagArray.activeHoursStart() = fastFirstNotNullOfOrNull(ActiveHoursStartTag::parse) + +fun TagArray.activeHoursEnd() = fastFirstNotNullOfOrNull(ActiveHoursEndTag::parse) fun TagArray.petName() = fastFirstNotNullOfOrNull(PetNameTag::parse) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt index 5146223894..17d1f2d14b 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.quartz.experimental.nip85TrustedAssertions import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag -import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PetNameTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.SummaryTag @@ -77,11 +76,13 @@ class ContactCardPetNameTest { ) val updated = - ContactCardEvent.updatePetNameAndSummary( - earlierVersion = card, - petName = "Bobby", - summary = "new summary", - signer = signer, + signer.sign( + ContactCardEvent.updatePetNameAndSummary( + earlierVersion = card, + petName = "Bobby", + summary = "new summary", + signer = signer, + ), ) assertEquals(targetUser, updated.aboutUser()) @@ -109,11 +110,13 @@ class ContactCardPetNameTest { ) val cleared = - ContactCardEvent.updatePetNameAndSummary( - earlierVersion = card, - petName = null, - summary = null, - signer = signer, + signer.sign( + ContactCardEvent.updatePetNameAndSummary( + earlierVersion = card, + petName = null, + summary = null, + signer = signer, + ), ) assertNull(cleared.privatePetName()) @@ -130,17 +133,19 @@ class ContactCardPetNameTest { ContactCardEvent.create( targetUser = targetUser, petName = "Bob :wave:", + emojis = listOf(oldEmoji), signer = signer, - privateInitializer = { emojis(listOf(oldEmoji)) }, ) assertEquals(listOf(oldEmoji), card.privateTags(signer)!!.mapNotNull(EmojiUrlTag::parse)) val updated = - ContactCardEvent.updatePetNameAndSummary( - earlierVersion = card, - petName = "Bob :soapbox:", - emojis = listOf(newEmoji), - signer = signer, + signer.sign( + ContactCardEvent.updatePetNameAndSummary( + earlierVersion = card, + petName = "Bob :soapbox:", + emojis = listOf(newEmoji), + signer = signer, + ), ) assertEquals("Bob :soapbox:", updated.privatePetName()) @@ -165,10 +170,12 @@ class ContactCardPetNameTest { assertEquals("public bob", card.petName()) val updated = - ContactCardEvent.updatePetNameAndSummary( - earlierVersion = card, - petName = "private bob", - signer = signer, + signer.sign( + ContactCardEvent.updatePetNameAndSummary( + earlierVersion = card, + petName = "private bob", + signer = signer, + ), ) assertNull(updated.petName()) From ab4b0b984d711eeec5e42d6b00998cb070dbe170 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 12:40:38 +0000 Subject: [PATCH 4/9] =?UTF-8?q?refactor:=20audit=20nickname=20code=20?= =?UTF-8?q?=E2=80=94=20dedupe=20emoji=20helpers=20into=20commons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review pass over the nickname/contact-card feature for reuse, simplicity and performance: - EmojiSuggestionState moves to commons next to EmojiPackState and now takes the EmojiPackState directly instead of the whole Android Account, so the desktop app can reuse the :shortcode: autocomplete - the findEmoji helper that was copy-pasted in 8 composer ViewModels is gone; everyone calls the shared EmojiPackState.findEmojiTags, which now resolves codes through a map lookup instead of a linear scan per code - ContactCardsState owns the emoji resolution for nickname saves (Account just publishes), takes EmojiPackState, and drops its unused scope param - new synchronous cachedPetName path (decryption-cache read, no crypto) seeds observeUserName/observeUserPetName initial values, removing the flash of the real name before the flow's first emission - nickname dialog prefill no longer clobbers text typed while a slow external signer decrypts the existing card Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY --- .../vitorpamplona/amethyst/model/Account.kt | 19 +++++----- .../reqCommand/user/UserObservers.kt | 14 ++++++-- .../nip22Comments/CommentPostViewModel.kt | 23 ++---------- .../privateDM/send/ChatNewMessageViewModel.kt | 18 ++-------- .../send/ChannelNewMessageViewModel.kt | 18 ++-------- .../nip23LongForm/LongFormPostViewModel.kt | 18 ++-------- .../nip99Classifieds/NewProductViewModel.kt | 18 ++-------- .../loggedIn/home/ShortNotePostViewModel.kt | 18 ++-------- .../room/chat/NestNewMessageViewModel.kt | 18 ++-------- .../NewPublicMessageViewModel.kt | 18 ++-------- .../profile/header/EditNicknameDialog.kt | 9 +++-- .../model/nip30CustomEmojis/EmojiPackState.kt | 3 +- .../EmojiSuggestionState.kt | 15 ++++---- .../ContactCardDecryptionCache.kt | 21 ++++++----- .../ContactCardsState.kt | 36 +++++++++++++------ 15 files changed, 99 insertions(+), 167 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis}/EmojiSuggestionState.kt (86%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 103170391b..a48e2fab1b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -420,9 +420,6 @@ class Account( val trustProviderListDecryptionCache = TrustProviderListDecryptionCache(signer) val trustProviderList = TrustProviderListState(signer, cache, trustProviderListDecryptionCache, scope, settings) - val contactCardDecryptionCache = ContactCardDecryptionCache(signer) - val contactCards = ContactCardsState(signer, cache, contactCardDecryptionCache, scope) - val peopleListDecryptionCache = PeopleListDecryptionCache(signer) val blockPeopleList = BlockPeopleListState(signer, cache, peopleListDecryptionCache, scope) val peopleLists = PeopleListsState(signer, cache, peopleListDecryptionCache, scope) @@ -440,6 +437,10 @@ class Account( val emoji = EmojiPackState(signer, cache, scope) val ownedEmojiPacks = OwnedEmojiPacksState(signer, cache, scope) + // needs `emoji` above: nickname edits resolve :shortcodes: against the account's packs + val contactCardDecryptionCache = ContactCardDecryptionCache(signer) + val contactCards = ContactCardsState(signer, cache, contactCardDecryptionCache, emoji) + val vanish = VanishRequestsState(signer, cache, client, scope) val appSpecific = AppSpecificState(signer, cache, scope, settings) @@ -3778,19 +3779,15 @@ class Account( /** * Nicknames a user by publishing the account's kind:30382 contact card about - * them, with the petname and summary NIP-44 encrypted in the content. Any - * `:shortcode:` from the account's emoji packs gets its NIP-30 emoji mapping - * embedded (also encrypted) so the nickname renders with custom emojis. - * `null` clears a field. Goes out through the account's extended outbox relays. + * them, with the petname, summary and their custom emoji mappings NIP-44 + * encrypted in the content. `null` clears a field. Goes out through the + * account's extended outbox relays. */ suspend fun updateContactCardPetName( pubkeyHex: HexKey, petName: String?, summary: String?, - ) { - val emojis = emoji.findEmojiTags(listOfNotNull(petName, summary).joinToString(" ")) - sendMyPublicAndPrivateOutbox(contactCards.updatePetNameAndSummary(pubkeyHex, petName, summary, emojis)) - } + ) = sendMyPublicAndPrivateOutbox(contactCards.updatePetNameAndSummary(pubkeyHex, petName, summary)) suspend fun showUser(pubkeyHex: HexKey) { sendMyPublicAndPrivateOutbox(blockPeopleList.showUser(pubkeyHex)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 768fe0996b..beb8cb9182 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -71,8 +71,15 @@ fun observeUserName( }.distinctUntilChanged() } + val initialName = + remember(user) { + accountViewModel.account.contactCards + .cachedPetName(user) + ?.petName ?: user.toBestDisplayName() + } + // Subscribe in the LocalCache for changes that arrive in the device - return flow.collectAsStateWithLifecycle(user.toBestDisplayName()) + return flow.collectAsStateWithLifecycle(initialName) } /** @@ -87,9 +94,10 @@ fun observeUserPetName( user: User, accountViewModel: AccountViewModel, ): State { - val flow = remember(user) { accountViewModel.account.contactCards.petNameFlow(user) } + val contactCards = accountViewModel.account.contactCards + val flow = remember(user) { contactCards.petNameFlow(user) } - return flow.collectAsStateWithLifecycle(null) + return flow.collectAsStateWithLifecycle(remember(user) { contactCards.cachedPetName(user) }) } @OptIn(ExperimentalCoroutinesApi::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 3c6b6d9b79..b4d9c028ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -34,6 +34,7 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.commons.ui.text.appendSignature import com.vitorpamplona.amethyst.commons.ui.text.currentWord @@ -56,7 +57,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField @@ -96,8 +96,6 @@ import com.vitorpamplona.quartz.nip22Comments.notify import com.vitorpamplona.quartz.nip29RelayGroups.groupId import com.vitorpamplona.quartz.nip29RelayGroups.hTag import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped -import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason @@ -275,7 +273,7 @@ open class CommentPostViewModel : this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() - this.emojiSuggestions = EmojiSuggestionState(accountVM.account) + this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji) } fun newPostFor(externalIdentity: ExternalId) { @@ -626,7 +624,7 @@ open class CommentPostViewModel : val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() - val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value) + val emojis = account.emoji.findEmojiTags(tagger.message) val urls = findURLs(tagger.message) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) @@ -717,21 +715,6 @@ open class CommentPostViewModel : return template } - fun findEmoji( - message: String, - myEmojiSet: List?, - ): List { - if (myEmojiSet == null) return emptyList() - return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> - myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { - EmojiUrlTag( - it.code, - it.link, - ) - } - } - } - fun upload( alt: String?, contentWarningReason: String?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index c5c94e64c6..fc6ed14ce5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -33,6 +33,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord @@ -46,7 +47,6 @@ import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField @@ -80,8 +80,6 @@ import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip19Bech32.toNpub -import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason @@ -276,7 +274,7 @@ class ChatNewMessageViewModel : ) this.emojiSuggestions?.reset() - this.emojiSuggestions = EmojiSuggestionState(accountVM.account) + this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji) this.uploadState = ChatFileUploadState( @@ -580,7 +578,7 @@ class ChatNewMessageViewModel : val messageText = message.text.toString() val urls = findURLs(messageText) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) - val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value) + val emojis = accountViewModel.account.emoji.findEmojiTags(messageText) val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null val message = messageText @@ -635,16 +633,6 @@ class ChatNewMessageViewModel : } } - fun findEmoji( - message: String, - myEmojiSet: List?, - ): List { - if (myEmojiSet == null) return emptyList() - return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> - myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) } - } - } - fun cancel() { draftTag.rotate() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index fe79dde482..eab9d48766 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay @@ -56,7 +57,6 @@ import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState @@ -88,8 +88,6 @@ import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip28PublicChat.base.notify import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip29RelayGroups.hTag -import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason @@ -212,7 +210,7 @@ open class ChannelNewMessageViewModel : ) this.emojiSuggestions?.reset() - this.emojiSuggestions = EmojiSuggestionState(accountVM.account) + this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji) this.uploadState = ChatFileUploadState(account.settings.defaultFileServer, account.settings.stripLocationOnUpload) } @@ -433,7 +431,7 @@ open class ChannelNewMessageViewModel : val urls = findURLs(messageText) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) - val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value) + val emojis = accountViewModel.account.emoji.findEmojiTags(messageText) val channelRelays = channel.relays() val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null @@ -597,16 +595,6 @@ open class ChannelNewMessageViewModel : } } - fun findEmoji( - message: String, - myEmojiSet: List?, - ): List { - if (myEmojiSet == null) return emptyList() - return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> - myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) } - } - } - open fun cancel() { draftTag.rotate() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index 7bc0a0f755..cb6d7bdc80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -35,6 +35,7 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.commons.ui.text.appendSignature import com.vitorpamplona.amethyst.commons.ui.text.currentWord @@ -60,7 +61,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField @@ -87,8 +87,6 @@ import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason @@ -234,7 +232,7 @@ class LongFormPostViewModel : this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() - this.emojiSuggestions = EmojiSuggestionState(accountVM.account) + this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji) } fun load( @@ -416,7 +414,7 @@ class LongFormPostViewModel : val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null val localZapRaiserAmount = if (wantsZapRaiser) zapRaiserAmount.value else null - val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value) + val emojis = account.emoji.findEmojiTags(tagger.message) val urls = findURLs(tagger.message) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) @@ -446,16 +444,6 @@ class LongFormPostViewModel : } } - private fun findEmoji( - message: String, - myEmojiSet: List?, - ): List { - if (myEmojiSet == null) return emptyList() - return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> - myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) } - } - } - fun uploadCoverImage( uri: SelectedMedia, context: Context, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index 0cbfe7a650..f4206076ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -34,6 +34,7 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord @@ -53,7 +54,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField @@ -79,8 +79,6 @@ import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes -import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason @@ -212,7 +210,7 @@ open class NewProductViewModel : this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() - this.emojiSuggestions = EmojiSuggestionState(accountVM.account) + this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji) } fun editFromDraft(draft: Note) { @@ -351,7 +349,7 @@ open class NewProductViewModel : ) tagger.run() - val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value) + val emojis = account.emoji.findEmojiTags(tagger.message) val urls = findURLs(tagger.message) val usedAttachments = iMetaDescription.filterIsIn(urls.toSet()) + productImages.map { it.toIMeta() } @@ -391,16 +389,6 @@ open class NewProductViewModel : return template } - fun findEmoji( - message: String, - myEmojiSet: List?, - ): List { - if (myEmojiSet == null) return emptyList() - return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> - myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) } - } - } - fun upload( alt: String?, contentWarningReason: String?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 3cea485aef..03e6d4c30b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -36,6 +36,7 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.commons.ui.text.appendSignature import com.vitorpamplona.amethyst.commons.ui.text.currentWord @@ -72,7 +73,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationController import com.vitorpamplona.amethyst.ui.actions.uploads.VoicePreset import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField @@ -124,8 +124,6 @@ import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip22Comments.notify import com.vitorpamplona.quartz.nip29RelayGroups.hTag -import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason @@ -517,7 +515,7 @@ open class ShortNotePostViewModel : this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() - this.emojiSuggestions = EmojiSuggestionState(accountVM.account) + this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji) } /** @@ -1201,7 +1199,7 @@ open class ShortNotePostViewModel : val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null val localZapRaiserAmount = if (wantsZapRaiser) zapRaiserAmount.value else null - val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value) + val emojis = account.emoji.findEmojiTags(tagger.message) val urls = findURLs(tagger.message) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) @@ -1381,16 +1379,6 @@ open class ShortNotePostViewModel : replyingToEvent.isClient(AccountCacheState.CLIENT_TAG_NAME) } - fun findEmoji( - message: String, - myEmojiSet: List?, - ): List { - if (myEmojiSet == null) return emptyList() - return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> - myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) } - } - } - fun upload( alt: String?, contentWarningReason: String?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt index 44bfb87c27..dd164b0792 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor @@ -50,7 +51,6 @@ import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState @@ -75,8 +75,6 @@ import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes -import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason @@ -212,7 +210,7 @@ open class NestNewMessageViewModel : ) this.emojiSuggestions?.reset() - this.emojiSuggestions = EmojiSuggestionState(accountVM.account) + this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji) this.uploadState = ChatFileUploadState(account.settings.defaultFileServer, account.settings.stripLocationOnUpload) } @@ -427,7 +425,7 @@ open class NestNewMessageViewModel : val urls = findURLs(messageText) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) - val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value) + val emojis = accountViewModel.account.emoji.findEmojiTags(messageText) val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null @@ -467,16 +465,6 @@ open class NestNewMessageViewModel : } } - fun findEmoji( - message: String, - myEmojiSet: List?, - ): List { - if (myEmojiSet == null) return emptyList() - return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> - myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) } - } - } - open fun cancel() { draftTag.rotate() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index a97e2068da..ff9988c888 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -34,6 +34,7 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord @@ -53,7 +54,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField @@ -84,8 +84,6 @@ import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds import com.vitorpamplona.quartz.nip19Bech32.toNpub -import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason @@ -222,7 +220,7 @@ class NewPublicMessageViewModel : this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() - this.emojiSuggestions = EmojiSuggestionState(accountVM.account) + this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji) } fun load(users: Set) { @@ -393,7 +391,7 @@ class NewPublicMessageViewModel : val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null - val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value) + val emojis = account.emoji.findEmojiTags(tagger.message) val urls = findURLs(tagger.message) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) @@ -419,16 +417,6 @@ class NewPublicMessageViewModel : } } - fun findEmoji( - message: String, - myEmojiSet: List?, - ): List { - if (myEmojiSet == null) return emptyList() - return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> - myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) } - } - } - fun upload( alt: String?, contentWarningReason: String?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt index dd133fa887..54f1a469a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt @@ -37,11 +37,11 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -64,20 +64,23 @@ fun EditNicknameDialog( ) { val nickname = rememberTextFieldState() val summary = rememberTextFieldState() - val emojiSuggestions = remember(accountViewModel) { EmojiSuggestionState(accountViewModel.account) } + val emojiSuggestions = remember(accountViewModel) { EmojiSuggestionState(accountViewModel.account.emoji) } // which field the emoji autocomplete should insert into val emojiTarget = remember { mutableStateOf(null) } // keeps the account's selected emoji packs loaded while the dialog is open WatchAndLoadMyEmojiList(accountViewModel) - // Prefill with the card's current encrypted values, if any. + // Prefill with the card's current encrypted values, if any. Decryption can + // be slow on external signers, so don't clobber anything already typed. LaunchedEffect(user) { accountViewModel.account.contactCards .petName(user.pubkeyHex) + ?.takeIf { nickname.text.isEmpty() } ?.let { nickname.setTextAndPlaceCursorAtEnd(it) } accountViewModel.account.contactCards .summary(user.pubkeyHex) + ?.takeIf { summary.text.isEmpty() } ?.let { summary.setTextAndPlaceCursorAtEnd(it) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt index 3aa74c34f4..decf5f345e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt @@ -134,11 +134,12 @@ class EmojiPackState( fun findEmojiTags(message: String): List { val myEmojiSet = myEmojis.value if (myEmojiSet.isEmpty()) return emptyList() + val byCode = myEmojiSet.associateBy { it.code } return CustomEmoji .findAllEmojiCodes(message) .distinct() .mapNotNull { code -> - myEmojiSet.firstOrNull { it.code == code }?.let { EmojiUrlTag(it.code, it.link) } + byCode[code]?.let { EmojiUrlTag(it.code, it.link) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/EmojiSuggestionState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiSuggestionState.kt similarity index 86% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/EmojiSuggestionState.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiSuggestionState.kt index 67fc66229b..08dec98379 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/EmojiSuggestionState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiSuggestionState.kt @@ -18,25 +18,28 @@ * 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.creators.emojiSuggestions +package com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState -import com.vitorpamplona.amethyst.model.Account import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn +/** + * Backs the `:shortcode:` autocomplete in text fields: feed the word under the + * cursor into [processCurrentWord] and collect [results] for the matching + * emojis from the account's selected packs. + */ @Stable class EmojiSuggestionState( - val account: Account, + val emojiPacks: EmojiPackState, ) { val search: MutableStateFlow = MutableStateFlow("") val results: Flow> = - account - .emoji.myEmojis + emojiPacks.myEmojis .combine(search) { list, search -> if (search.length == 1) { list diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt index 42b9142a8e..f47c2204c2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent @@ -38,10 +39,6 @@ class ContactCardDecryptionCache( ) { val cachedPrivateCards = PrivateTagArrayEventCache(signer, cacheSize = 100) - fun cachedPetName(event: ContactCardEvent) = cachedPrivateCards.mergeTagListPrecached(event).petName() - - fun cachedSummary(event: ContactCardEvent) = cachedPrivateCards.mergeTagListPrecached(event).summary() - suspend fun petName(event: ContactCardEvent) = cachedPrivateCards.mergeTagList(event).petName() suspend fun summary(event: ContactCardEvent) = cachedPrivateCards.mergeTagList(event).summary() @@ -50,9 +47,17 @@ class ContactCardDecryptionCache( * The petname plus the card's full decrypted tag list, so renderers can * resolve the NIP-30 `emoji` mappings stored alongside it. */ - suspend fun petNameWithEmojis(event: ContactCardEvent): PetName? { - val merged = cachedPrivateCards.mergeTagList(event) - val name = merged.petName() ?: return null - return PetName(name, merged.toImmutableListOfLists()) + suspend fun petNameWithEmojis(event: ContactCardEvent): PetName? = cachedPrivateCards.mergeTagList(event).toPetName() + + /** + * 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() + + private fun TagArray.toPetName(): PetName? { + val name = petName() ?: return null + return PetName(name, toImmutableListOfLists()) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt index 1a651c27eb..9ed53fc670 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt @@ -24,12 +24,11 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.IO @@ -55,7 +54,7 @@ class ContactCardsState( val signer: NostrSigner, val cache: ICacheProvider, val decryptionCache: ContactCardDecryptionCache, - val scope: CoroutineScope, + val emojiPacks: EmojiPackState, ) { private val accountUser: User? by lazy { cache.getOrCreateUser(signer.pubKey) } @@ -67,8 +66,8 @@ class ContactCardsState( /** * The account's own card about [target], as attached to the target user's - * [UserCardsCache] when the event is consumed. Reads from the existing - * received-cards map so displaying users without a card allocates nothing new. + * [UserCardsCache] when the event is consumed. `cards()` lazily allocates the + * per-user cache (like `metadata()` does) so a card arriving later is seen. */ @OptIn(ExperimentalCoroutinesApi::class) fun myCardFlow(target: User): Flow = @@ -97,22 +96,39 @@ class ContactCardsState( .distinctUntilChanged() .flowOn(Dispatchers.IO) + /** + * Synchronously returns the petname 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? { + val card = + target + .cardsOrNull() + ?.receivedCards + ?.value + ?.get(accountUser) + ?.event as? ContactCardEvent ?: return null + return decryptionCache.cachedPetNameWithEmojis(card) + } + suspend fun petName(target: HexKey): String? = getCard(target)?.let { decryptionCache.petName(it) } suspend fun summary(target: HexKey): String? = getCard(target)?.let { decryptionCache.summary(it) } /** - * Builds the new signed card for [target] with the given petname, summary and - * the NIP-30 emoji mappings their shortcodes use (all stored NIP-44 encrypted; - * `null` clears a field), preserving every other tag of an existing card. The - * caller is responsible for publishing it. + * Builds the new signed card for [target] with the given petname and summary + * (`null` clears a field), preserving every other tag of an existing card. + * Any `:shortcode:` from the account's emoji packs gets its NIP-30 emoji + * mapping embedded; everything is stored NIP-44 encrypted. The caller is + * responsible for publishing it. */ suspend fun updatePetNameAndSummary( target: HexKey, petName: String?, summary: String?, - emojis: List = emptyList(), ): ContactCardEvent { + val emojis = emojiPacks.findEmojiTags(listOfNotNull(petName, summary).joinToString(" ")) val existing = getCard(target) return if (existing != null) { signer.sign( From b69304c4a88a29848ac606e89937daa6c6b6180f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 15:12:15 +0000 Subject: [PATCH 5/9] fix: import EmojiSuggestionState from commons in ShowEmojiSuggestionList MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShowEmojiSuggestionList sat in the same package as EmojiSuggestionState, so it referenced the class without an import — moving the class to commons broke the Android compile. Caught by an honest verification run: earlier gradle invocations were targeting a nonexistent :amethyst:compileDebugKotlin task (the app builds flavored compilePlayDebugKotlin/compileFdroidDebugKotlin) with the failure masked behind a pipe, so the last three commits had never actually been compiled. Verified at this commit: quartz nip85 tests (13/13), :commons:compileKotlinJvm, :cli:compileKotlin, :amethyst:compilePlayDebugKotlin, :desktopApp:compileKotlin. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY --- .../ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt index b6e6ec3e8a..953a044747 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt @@ -43,6 +43,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.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size10dp From e8ecf429a20438c7531faa279cbadd6fbfe8103f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 21:20:36 +0000 Subject: [PATCH 6/9] refactor: move nickname policy, emoji autocomplete, filters and dialog to commons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalizes the remaining platform-bound pieces of the nickname feature so the desktop app can adopt it: - ContactCardsState.displayNameFlow/cachedDisplayName own the NIP-81 render policy (petname over profile name over npub); the Android observeUserName is now a thin wrapper around it - EmojiSuggestionState.autocompleteInto completes the word under the cursor and closes the list — replaces the insert+reset pair copy-pasted in the 8 composer ViewModels and the nickname dialog - the kind:30382 filter builders move to commons relayClient/assemblers (cards about targets from trusted accounts, and the account's own cards by author), shared by the user watcher and the login subscription - ShowEmojiSuggestionList moves to commons nip30CustomEmojis/ui using coil and commons string resources - EditNicknameDialog moves to commons nip85TrustedAssertions/ui: it takes the ContactCardsState and an onSave callback, so each front end only wires its own publish path and menu entry; dialog strings move to commons resources Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY --- .../FilterAccountInfoAndListsFromKey.kt | 21 +--- .../reqCommand/user/UserObservers.kt | 22 +--- .../user/watchers/UserCardsSubAssembler.kt | 1 + .../nip22Comments/CommentPostViewModel.kt | 6 +- .../nip22Comments/GenericCommentPostScreen.kt | 2 +- .../privateDM/send/ChatNewMessageViewModel.kt | 6 +- .../chats/privateDM/send/NewGroupDMScreen.kt | 2 +- .../send/PrivateMessageEditFieldRow.kt | 2 +- .../send/ChannelNewMessageViewModel.kt | 5 +- .../chats/publicChannels/send/EditFieldRow.kt | 2 +- .../nip23LongForm/LongFormPostScreen.kt | 2 +- .../nip23LongForm/LongFormPostViewModel.kt | 4 +- .../nip99Classifieds/NewProductScreen.kt | 2 +- .../nip99Classifieds/NewProductViewModel.kt | 6 +- .../loggedIn/home/ShortNotePostScreen.kt | 2 +- .../loggedIn/home/ShortNotePostViewModel.kt | 6 +- .../nests/room/chat/NestEditFieldRow.kt | 2 +- .../room/chat/NestNewMessageViewModel.kt | 5 +- .../publicMessages/NewPublicMessageScreen.kt | 2 +- .../NewPublicMessageViewModel.kt | 6 +- .../profile/header/UserProfileDropDownMenu.kt | 7 +- amethyst/src/main/res/values/strings.xml | 6 +- .../composeResources/values/strings.xml | 11 ++ .../nip30CustomEmojis/EmojiSuggestionState.kt | 14 +++ .../ContactCardsState.kt | 17 +++ .../ui}/ShowEmojiSuggestionList.kt | 22 ++-- .../ui}/EditNicknameDialog.kt | 110 +++++++++--------- .../assemblers/ContactCardFilters.kt | 29 ++++- 28 files changed, 175 insertions(+), 147 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip30CustomEmojis/ui}/ShowEmojiSuggestionList.kt (86%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip85TrustedAssertions/ui}/EditNicknameDialog.kt (55%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterContactCardsToKey.kt => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt (69%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 967bbd4f45..673e6cba60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata +import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterContactCardsByAuthorInTheRelay import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent @@ -49,7 +50,6 @@ import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent @@ -94,12 +94,6 @@ val AccountInfoAndListsFromKeyKinds2 = NutzapInfoEvent.KIND, ) -// The account's own kind:30382 contact cards (nicknames/petnames for other -// users, NIP-44 encrypted). Addressable: one card per target user, so this is a -// collection rather than a single replaceable — kept out of the small-limit -// filters above and given its own filter with a larger limit. -val AccountContactCardKinds = listOf(ContactCardEvent.KIND) - val AmethystMetadataKinds = listOf(AppSpecificDataEvent.KIND) val AmethystMetadataTagMapFilter = mapOf("d" to listOf(APP_SPECIFIC_DATA_D_TAG)) @@ -131,15 +125,12 @@ fun filterAccountInfoAndListsFromKey( since = since, ), ), - RelayBasedFilter( + // The account's own kind:30382 contact cards (nicknames, NIP-44 encrypted). + // Addressable — one card per target user — hence its own larger-limit filter. + filterContactCardsByAuthorInTheRelay( relay = relay, - filter = - Filter( - kinds = AccountContactCardKinds, - authors = listOf(pubkey), - limit = 500, - since = since, - ), + author = pubkey, + since = since, ), RelayBasedFilter( relay = relay, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index beb8cb9182..26dcbe7c94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -61,25 +61,11 @@ fun observeUserName( // Subscribe in the relay for changes in the metadata of this user. UserFinderFilterAssemblerSubscription(user, accountViewModel) - val flow = - remember(user) { - combine( - user.metadata().flow, - accountViewModel.account.contactCards.petNameFlow(user), - ) { info, petName -> - petName?.petName ?: info?.info?.bestName() ?: user.pubkeyDisplayHex() - }.distinctUntilChanged() - } - - val initialName = - remember(user) { - accountViewModel.account.contactCards - .cachedPetName(user) - ?.petName ?: user.toBestDisplayName() - } + val contactCards = accountViewModel.account.contactCards + val flow = remember(user) { contactCards.displayNameFlow(user) } // Subscribe in the LocalCache for changes that arrive in the device - return flow.collectAsStateWithLifecycle(initialName) + return flow.collectAsStateWithLifecycle(remember(user) { contactCards.cachedDisplayName(user) }) } /** @@ -323,7 +309,7 @@ fun observeUserBookmarkCount( val combined = remember(user) { - kotlinx.coroutines.flow.combine(newFlow, oldFlow) { newCount, oldCount -> + combine(newFlow, oldFlow) { newCount, oldCount -> newCount + oldCount } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt index 19474b6c48..bd297744e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers import com.vitorpamplona.amethyst.commons.model.toHexSet +import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index b4d9c028ff..0943395636 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -920,13 +920,9 @@ open class CommentPostViewModel : } open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { - val wordToInsert = ":${item.code}:" - - message.replaceCurrentWord(wordToInsert) + emojiSuggestions?.autocompleteInto(message, item) urlPreviews.update(message.text.toString()) - emojiSuggestions?.reset() - draftTag.newVersion() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index 8d4497e18d..ae2542cbcc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -52,6 +52,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel 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.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles @@ -65,7 +66,6 @@ import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index fc6ed14ce5..2d92030b7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -764,13 +764,9 @@ class ChatNewMessageViewModel : } fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { - val wordToInsert = ":${item.code}:" - - message.replaceCurrentWord(wordToInsert) + emojiSuggestions?.autocompleteInto(message, item) urlPreviews.update(message.text.toString()) - emojiSuggestions?.reset() - draftTag.newVersion() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index 36987b6160..8941be093f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.Amethyst 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.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo @@ -93,7 +94,6 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index a089688b21..fc64efe0ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -51,6 +51,7 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation @@ -63,7 +64,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.note.showCount import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index eab9d48766..377b424bbb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -678,10 +678,7 @@ open class ChannelNewMessageViewModel : } open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { - val wordToInsert = ":${item.code}:" - message.replaceCurrentWord(wordToInsert) - - emojiSuggestions?.reset() + emojiSuggestions?.autocompleteInto(message, item) draftTag.newVersion() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index e9f150b50a..f23c35fce7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation @@ -44,7 +45,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index 95cdc5577c..93c8a6b0ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -83,6 +83,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.EmptyTagList +import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation @@ -99,7 +100,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index cb6d7bdc80..47686de456 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -692,9 +692,7 @@ class LongFormPostViewModel : } fun autocompleteWithEmoji(item: EmojiMedia) { - val wordToInsert = ":${item.code}:" - message.replaceCurrentWord(wordToInsert) - emojiSuggestions?.reset() + emojiSuggestions?.autocompleteInto(message, item) draftTag.newVersion() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index 6f4fd20c97..9f32382d97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -58,7 +59,6 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index f4206076ac..6e7554d7dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -558,13 +558,9 @@ open class NewProductViewModel : } open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { - val wordToInsert = ":${item.code}:" - - message.replaceCurrentWord(wordToInsert) + emojiSuggestions?.autocompleteInto(message, item) urlPreviews.update(message.text.toString()) - emojiSuggestions?.reset() - draftTag.newVersion() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 534927c0f1..7453377467 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -76,6 +76,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel 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.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS @@ -99,7 +100,6 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.creators.aihelp.AiWritingHelpPanel import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 03e6d4c30b..c7ef8c9626 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -1610,13 +1610,9 @@ open class ShortNotePostViewModel : } open fun autocompleteWithEmoji(item: EmojiMedia) { - val wordToInsert = ":${item.code}:" - - message.replaceCurrentWord(wordToInsert) + emojiSuggestions?.autocompleteInto(message, item) urlPreviews.update(message.text.toString()) - emojiSuggestions?.reset() - draftTag.newVersion() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestEditFieldRow.kt index f6fa574b3e..88a8f1e51f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestEditFieldRow.kt @@ -36,13 +36,13 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt index dd164b0792..63a55b94c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt @@ -548,10 +548,7 @@ open class NestNewMessageViewModel : } open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { - val wordToInsert = ":${item.code}:" - message.replaceCurrentWord(wordToInsert) - - emojiSuggestions?.reset() + emojiSuggestions?.autocompleteInto(message, item) draftTag.newVersion() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index 06d46689e6..f10bd8db4e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -55,6 +55,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation @@ -69,7 +70,6 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index ff9988c888..68ffbf6006 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -621,13 +621,9 @@ class NewPublicMessageViewModel : } fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { - val wordToInsert = ":${item.code}:" - - message.replaceCurrentWord(wordToInsert) + emojiSuggestions?.autocompleteInto(message, item) urlPreviews.update(message.text.toString()) - emojiSuggestions?.reset() - draftTag.newVersion() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt index 71e9ac8a38..258f7dbf82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt @@ -29,11 +29,13 @@ import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import com.vitorpamplona.amethyst.R 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.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.note.externalLinkForUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -50,10 +52,13 @@ fun UserProfileDropDownMenu( val isNicknameDialogOpen = remember { mutableStateOf(false) } if (isNicknameDialogOpen.value) { + // keeps the account's selected emoji packs loaded for the : autocomplete + WatchAndLoadMyEmojiList(accountViewModel) EditNicknameDialog( user = user, + contactCards = accountViewModel.account.contactCards, + onSave = { petName, summary -> accountViewModel.updateContactCardPetName(user, petName, summary) }, onDismiss = { isNicknameDialogOpen.value = false }, - accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c6f381f16e..8c0897ec31 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3543,12 +3543,8 @@ Playback Auto - + Edit nickname - Nickname - Shown to you instead of this user\'s name, everywhere in the app. It\'s saved encrypted in your contact card: only you can read it. Type : to use your custom emojis. - Nickname - Private note about this user Cast to device diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml index f7cbf115a9..e50c831382 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commons/src/commonMain/composeResources/values/strings.xml @@ -90,4 +90,15 @@ Source: Servers: Open + + + Use direct URL + + + Nickname + Shown to you instead of this user\'s name, everywhere in the app. It\'s saved encrypted in your contact card: only you can read it. Type : to use your custom emojis. + Nickname + Private note about this user + Save + Cancel diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiSuggestionState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiSuggestionState.kt index 08dec98379..0f5e457153 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiSuggestionState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiSuggestionState.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow @@ -66,4 +68,16 @@ class EmojiSuggestionState( } } } + + /** + * Completes the word under the cursor in [field] with the selected emoji's + * `:shortcode:` and closes the suggestion list. + */ + fun autocompleteInto( + field: TextFieldState, + item: EmojiPackState.EmojiMedia, + ) { + field.replaceCurrentWord(":${item.code}:") + reset() + } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt index 9ed53fc670..25b370002d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf @@ -112,6 +113,22 @@ class ContactCardsState( return decryptionCache.cachedPetNameWithEmojis(card) } + /** + * The name to render for [target], per the NIP-81 policy: the nickname the + * account gave them wins over the profile's own display name, falling back + * to the short npub when neither exists. + */ + fun displayNameFlow(target: User): Flow = + combine( + target.metadata().flow, + petNameFlow(target), + ) { info, petName -> + petName?.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() + suspend fun petName(target: HexKey): String? = getCard(target)?.let { decryptionCache.petName(it) } suspend fun summary(target: HexKey): String? = getCard(target)?.let { decryptionCache.summary(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip30CustomEmojis/ui/ShowEmojiSuggestionList.kt similarity index 86% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip30CustomEmojis/ui/ShowEmojiSuggestionList.kt index 953a044747..61d0048193 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip30CustomEmojis/ui/ShowEmojiSuggestionList.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.note.creators.emojiSuggestions +package com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement.spacedBy @@ -39,15 +39,17 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage -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.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size40Modifier +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.use_direct_url +import org.jetbrains.compose.resources.stringResource + +private val DividerThickness = 0.25.dp +private val RowSpacing = 10.dp +private val EmojiSize = Modifier.size(40.dp) @Composable fun ShowEmojiSuggestionList( @@ -73,23 +75,23 @@ fun ShowEmojiSuggestionList( bottom = 10.dp, ), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = spacedBy(Size10dp), + horizontalArrangement = spacedBy(RowSpacing), ) { AsyncImage( it.link, contentDescription = it.code, - modifier = Size40Modifier, + modifier = EmojiSize, ) Text(it.code, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) IconButton( - modifier = Size40Modifier, + modifier = EmojiSize, onClick = { onFullSize(it) }, ) { Icon( symbol = MaterialSymbols.OpenInFull, - contentDescription = stringRes(R.string.use_direct_url), + contentDescription = stringResource(Res.string.use_direct_url), modifier = Modifier.size(20.dp), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip85TrustedAssertions/ui/EditNicknameDialog.kt similarity index 55% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip85TrustedAssertions/ui/EditNicknameDialog.kt index 54f1a469a3..b24c98cbb5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip85TrustedAssertions/ui/EditNicknameDialog.kt @@ -18,35 +18,41 @@ * 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 +package com.vitorpamplona.amethyst.commons.nip85TrustedAssertions.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState +import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState +import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.nickname_cancel +import com.vitorpamplona.amethyst.commons.resources.nickname_dialog_explainer +import com.vitorpamplona.amethyst.commons.resources.nickname_dialog_title +import com.vitorpamplona.amethyst.commons.resources.nickname_label +import com.vitorpamplona.amethyst.commons.resources.nickname_save +import com.vitorpamplona.amethyst.commons.resources.nickname_summary_label import com.vitorpamplona.amethyst.commons.ui.text.currentWord -import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList -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.placeholderText +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource /** * Edits the nickname (petname) and private note (summary) the account keeps for @@ -55,79 +61,82 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText * * Typing `:` offers the account's NIP-30 custom emojis; the mappings for any * shortcode used are embedded (also encrypted) so the nickname renders with them. + * + * Shared by every front end: the caller supplies the account's [contactCards] + * and publishes the result in [onSave] (e.g. through its outbox relays). On + * Android, compose `WatchAndLoadMyEmojiList` alongside so the emoji packs load. */ @Composable fun EditNicknameDialog( user: User, + contactCards: ContactCardsState, + onSave: (petName: String?, summary: String?) -> Unit, onDismiss: () -> Unit, - accountViewModel: AccountViewModel, ) { val nickname = rememberTextFieldState() val summary = rememberTextFieldState() - val emojiSuggestions = remember(accountViewModel) { EmojiSuggestionState(accountViewModel.account.emoji) } - // which field the emoji autocomplete should insert into + val emojiSuggestions = remember(contactCards) { EmojiSuggestionState(contactCards.emojiPacks) } + // which field the emoji autocomplete should insert into: the last one edited val emojiTarget = remember { mutableStateOf(null) } - // keeps the account's selected emoji packs loaded while the dialog is open - WatchAndLoadMyEmojiList(accountViewModel) - // Prefill with the card's current encrypted values, if any. Decryption can // be slow on external signers, so don't clobber anything already typed. LaunchedEffect(user) { - accountViewModel.account.contactCards + contactCards .petName(user.pubkeyHex) ?.takeIf { nickname.text.isEmpty() } ?.let { nickname.setTextAndPlaceCursorAtEnd(it) } - accountViewModel.account.contactCards + contactCards .summary(user.pubkeyHex) ?.takeIf { summary.text.isEmpty() } ?.let { summary.setTextAndPlaceCursorAtEnd(it) } } - fun watchEmojiIn(field: TextFieldState) { - emojiTarget.value = field - if (field.selection.collapsed) { - emojiSuggestions.processCurrentWord(field.currentWord()) + // Feed the word under the cursor of the last-edited field to the autocomplete. + LaunchedEffect(nickname, summary) { + fun watch(field: TextFieldState) { + emojiTarget.value = field + if (field.selection.collapsed) { + emojiSuggestions.processCurrentWord(field.currentWord()) + } } + launch { snapshotFlow { nickname.text }.collect { watch(nickname) } } + launch { snapshotFlow { summary.text }.collect { watch(summary) } } } AlertDialog( onDismissRequest = onDismiss, title = { - Text(text = stringRes(R.string.nickname_dialog_title)) + Text(text = stringResource(Res.string.nickname_dialog_title)) }, text = { Column( verticalArrangement = Arrangement.spacedBy(10.dp), ) { Text( - text = stringRes(R.string.nickname_dialog_explainer), - color = MaterialTheme.colorScheme.placeholderText, + text = stringResource(Res.string.nickname_dialog_explainer), + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - ThinPaddingTextField( + OutlinedTextField( state = nickname, - onTextChanged = { watchEmojiIn(nickname) }, - singleLine = true, + lineLimits = TextFieldLineLimits.SingleLine, label = { - Text(text = stringRes(R.string.nickname_label)) + Text(text = stringResource(Res.string.nickname_label)) }, ) - ThinPaddingTextField( + OutlinedTextField( state = summary, - onTextChanged = { watchEmojiIn(summary) }, label = { - Text(text = stringRes(R.string.nickname_summary_label)) + Text(text = stringResource(Res.string.nickname_summary_label)) }, ) ShowEmojiSuggestionList( emojiSuggestions, - onSelect = { - emojiTarget.value?.replaceCurrentWord(":${it.code}:") - emojiSuggestions.reset() + onSelect = { emoji -> + emojiTarget.value?.let { emojiSuggestions.autocompleteInto(it, emoji) } }, - onFullSize = { - emojiTarget.value?.replaceCurrentWord(":${it.code}:") - emojiSuggestions.reset() + onFullSize = { emoji -> + emojiTarget.value?.let { emojiSuggestions.autocompleteInto(it, emoji) } }, modifier = Modifier.heightIn(max = 200.dp), ) @@ -136,30 +145,27 @@ fun EditNicknameDialog( confirmButton = { Button( onClick = { - accountViewModel.updateContactCardPetName( - user = user, - petName = - nickname.text - .toString() - .trim() - .ifBlank { null }, - summary = - summary.text - .toString() - .trim() - .ifBlank { null }, + onSave( + nickname.text + .toString() + .trim() + .ifBlank { null }, + summary.text + .toString() + .trim() + .ifBlank { null }, ) onDismiss() }, ) { - Text(stringRes(R.string.save)) + Text(stringResource(Res.string.nickname_save)) } }, dismissButton = { Button( onClick = onDismiss, ) { - Text(stringRes(R.string.cancel)) + Text(stringResource(Res.string.nickname_cancel)) } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterContactCardsToKey.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt similarity index 69% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterContactCardsToKey.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt index 8013013f99..f75e6819f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterContactCardsToKey.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.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.service.relayClient.reqCommand.user.watchers +package com.vitorpamplona.amethyst.commons.relayClient.assemblers import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -28,6 +28,11 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent val ContactCardKindList = listOf(ContactCardEvent.KIND) +/** + * Kind:30382 cards *about* [targets], written by [trustedAccounts] (the account + * itself plus its WoT trust providers). Fetches nicknames and scores for the + * users currently on screen. + */ fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( targets: Set, trustedAccounts: List, @@ -46,3 +51,25 @@ fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( ), ) } + +/** + * Every kind:30382 card *written by* [author] — the account's own nicknames — + * for the bulk download at login from the account's relays. Addressable events: + * one card per target user, hence the larger limit. + */ +fun filterContactCardsByAuthorInTheRelay( + relay: NormalizedRelayUrl, + author: HexKey, + since: Long?, + limit: Int = 500, +): RelayBasedFilter = + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = ContactCardKindList, + authors = listOf(author), + limit = limit, + since = since, + ), + ) From a6da2ee69da095210914f52b634b29f938c4d823 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 21:52:29 +0000 Subject: [PATCH 7/9] fix: record contact-card EOSEs from the d-tag so card syncs are incremental MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserCardsSubAssembler.newEose read the filter's p-tags to stamp each target user's card EOSE, but kind:30382 filters address targets in the d-tag — so no per-user EOSE was ever recorded, groupByRelayPresence always classified users as never-checked, and every filter update re-downloaded the visible users' cards with since = null. Read the d-tag instead, and use DTag.TAG_NAME on both the filter builder and the EOSE reader so the two keys cannot drift apart again. Pre-existing on main; surfaced while verifying that card syncs are incremental from the account's outbox relays. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY --- .../reqCommand/user/watchers/UserCardsSubAssembler.kt | 6 +++++- .../commons/relayClient/assemblers/ContactCardFilters.kt | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt index bd297744e8..5ab831c9c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag import com.vitorpamplona.quartz.utils.mapOfSet class UserCardsSubAssembler( @@ -46,7 +47,10 @@ class UserCardsSubAssembler( filters: List?, ) { filters?.forEach { filter -> - filter.tags?.get("p")?.forEach { + // kind:30382 addresses the target user in the d-tag (the key the + // filter builder uses). Reading any other tag leaves the per-user + // EOSEs unset and forces full re-downloads with since = null. + filter.tags?.get(DTag.TAG_NAME)?.forEach { val targetUser = cache.getUserIfExists(it) targetUser?.cardsOrNull()?.latestEOSEs?.newEose(relay, time) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt index f75e6819f2..9eab40c51a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent val ContactCardKindList = listOf(ContactCardEvent.KIND) @@ -46,7 +47,8 @@ fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( Filter( kinds = ContactCardKindList, authors = trustedAccounts, - tags = mapOf("d" to targets.sorted()), + // kind:30382 addresses the target user in the d-tag + tags = mapOf(DTag.TAG_NAME to targets.sorted()), since = since, ), ) From b76b37744ea23e070c1e48df0dd3b9bee36f465e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 23:33:05 +0000 Subject: [PATCH 8/9] feat: nickname card on the user profile, above the real display name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY --- .../reqCommand/user/UserObservers.kt | 20 +-- .../amethyst/ui/components/ClickableRoute.kt | 9 +- .../amethyst/ui/components/RichTextViewer.kt | 9 +- .../amethyst/ui/note/UsernameDisplay.kt | 9 +- .../loggedIn/chats/feed/DrawAuthorInfo.kt | 11 +- .../profile/header/DrawAdditionalInfo.kt | 13 +- .../profile/header/UserNicknameCard.kt | 125 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 1 + .../ContactCardDecryptionCache.kt | 16 ++- .../ContactCardsState.kt | 23 ++-- .../{PetName.kt => Nickname.kt} | 21 ++- 11 files changed, 198 insertions(+), 59 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt rename commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/{PetName.kt => Nickname.kt} (66%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 26dcbe7c94..82a1de7a66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -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 { +): State { 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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index b5a61c19bb..4423e58a9c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 56ce3b6411..54e13bb3d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -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, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index c36934bbb5..f8fdd1f072 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt index c05a352e98..35a6fcae36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt @@ -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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 33c17b342a..17b801c28a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt new file mode 100644 index 0000000000..cf678ff575 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt @@ -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), + ) + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 8c0897ec31..5ec3ebfd23 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3545,6 +3545,7 @@ Edit nickname + Only visible to you Cast to device diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt index f47c2204c2..e6e5477791 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt @@ -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()) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt index 25b370002d..f643429c5c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt @@ -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 = + fun nicknameFlow(target: User): Flow = 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 = 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) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/PetName.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/Nickname.kt similarity index 66% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/PetName.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/Nickname.kt index 1cabf8e564..1d678013a8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/PetName.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/Nickname.kt @@ -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, ) { // 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() } From d2f02fbd321d476ffec040dfcce73b69a5a59946 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 23:52:13 +0000 Subject: [PATCH 9/9] style: double the nickname card's top margin, halve its bottom margin Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY --- .../ui/screen/loggedIn/profile/header/UserNicknameCard.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt index cf678ff575..a09f1d78bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt @@ -80,7 +80,7 @@ fun UserNicknameCard( OutlinedCard( onClick = { isEditDialogOpen.value = true }, - modifier = Modifier.fillMaxWidth().padding(top = 7.dp), + modifier = Modifier.fillMaxWidth().padding(top = 14.dp, bottom = 3.5.dp), ) { Box(modifier = Modifier.fillMaxWidth()) { Column(