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 e208855eeb..b61da3d44a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -50,6 +50,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 @@ -567,6 +569,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) @@ -4528,6 +4534,18 @@ class Account( sendMyPublicAndPrivateOutbox(muteList.hideUser(pubkeyHex)) } + /** + * Nicknames a user by publishing the account's kind:30382 contact card about + * 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?, + ) = 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/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index f3fff5ce5b..a4d1696290 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -3915,7 +3915,15 @@ object LocalCache : ILocalCache, ICacheProvider { } is GiftWrapEvent -> { - consumeRegularEvent(event, relay, wasVerified) + // A wrap with an empty content carries no NIP-44 ciphertext and can + // never be unwrapped — reject it before paying for a signature check + // and a cache slot. Locally stripped copies (copyNoContent) are + // assigned straight to note.event and never pass through here. + if (event.content.isEmpty()) { + false + } else { + consumeRegularEvent(event, relay, wasVerified) + } } is GroupEvent -> { 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 8ee5c6863d..ae7323972e 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.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent @@ -131,6 +132,13 @@ fun filterAccountInfoAndListsFromKey( since = since, ), ), + // 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, + author = pubkey, + 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..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,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.Nickname import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.NoteState @@ -43,6 +44,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 @@ -59,18 +61,29 @@ fun observeUserName( // Subscribe in the relay for changes in the metadata of this user. UserFinderFilterAssemblerSubscription(user, accountViewModel) - val flow = - remember(user) { - user - .metadata() - .flow - .map { - it?.info?.bestName() ?: user.pubkeyDisplayHex() - }.distinctUntilChanged() - } + 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(user.toBestDisplayName()) + return flow.collectAsStateWithLifecycle(remember(user) { contactCards.cachedDisplayName(user) }) +} + +/** + * 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 observeUserNickname( + user: User, + accountViewModel: AccountViewModel, +): State { + val contactCards = accountViewModel.account.contactCards + val flow = remember(user) { contactCards.nicknameFlow(user) } + + return flow.collectAsStateWithLifecycle(remember(user) { contactCards.cachedNickname(user) }) } @OptIn(ExperimentalCoroutinesApi::class) @@ -296,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..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 @@ -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 @@ -32,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( @@ -45,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/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index a0533e07cc..32780c6599 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 @@ -61,6 +61,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.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 @@ -313,14 +314,16 @@ fun RenderUserAsClickableText( nav: INav, ) { val userState by observeUserInfo(baseUser, accountViewModel) + val nickname by observeUserNickname(baseUser, accountViewModel) + val petName = nickname?.petName 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) }, nav = nav, - 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 5a45896216..7d9e46150a 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 @@ -106,6 +106,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.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 @@ -1013,15 +1014,17 @@ private fun DisplayUserFromTag( nav: INav, ) { val meta by observeUserInfo(baseUser, accountViewModel) + val nickname by observeUserNickname(baseUser, accountViewModel) + val petName = nickname?.petName 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, - 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 e7f64cd167..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,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.observeUserNickname import com.vitorpamplona.amethyst.service.tts.TextToSpeechHelper import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji @@ -105,11 +106,15 @@ fun UsernameDisplay( accountViewModel: AccountViewModel, ) { val userMetadata by observeUserInfo(baseUser, accountViewModel) + val nickname by observeUserNickname(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; + // its custom emojis resolve against the contact card's tags, not the profile's + val petName = nickname?.petName + val name = petName ?: it?.info?.bestName() if (name != null) { - UserDisplay(name, 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/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 0183fee6c3..79bc61906c 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 @@ -97,8 +97,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 @@ -291,7 +289,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) { @@ -645,7 +643,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()) @@ -736,21 +734,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?, @@ -953,13 +936,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 9d06d1f355..e67b48fc9e 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/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index b9c3c80530..8a03fc1da2 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 @@ -1771,6 +1771,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..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,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.observeUserNickname 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,15 @@ private fun WatchAndDisplayUser( nav: INav, ) { val userState by observeUserInfo(author, accountViewModel) + val nickname by observeUserNickname(author, accountViewModel) + val petName = nickname?.petName 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,8 +84,8 @@ private fun WatchAndDisplayUser( name = { if (userState != null) { CreateTextWithEmoji( - text = userState?.info?.bestName() ?: author.pubkeyDisplayHex(), - 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/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index c5c94e64c6..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 @@ -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() @@ -776,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 4291038c01..995a5367d6 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 @@ -57,7 +58,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 @@ -90,8 +90,6 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent 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 @@ -218,7 +216,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) } @@ -446,7 +444,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 @@ -629,16 +627,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() @@ -722,10 +710,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 9908a7a753..3c19a37df6 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 7bc0a0f755..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 @@ -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, @@ -704,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 0cbfe7a650..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 @@ -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?, @@ -570,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 a9cb01b725..342e495a1d 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 edc2f40266..f18b41f796 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 @@ -536,7 +534,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) } /** @@ -1241,7 +1239,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()) @@ -1421,16 +1419,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?, @@ -1659,13 +1647,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 44bfb87c27..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 @@ -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() @@ -560,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/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index f10921bd06..124e578ad0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -64,6 +64,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEven import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent @@ -79,6 +80,7 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import kotlinx.coroutines.flow.MutableStateFlow @@ -119,33 +121,47 @@ class NotificationFeedFilter( ) val NOTIFICATION_KINDS = - // The core subscription kinds are shared with Desktop through - // `commons/.../moderation/notifications/NotificationKinds.SUBSCRIPTION_KINDS` - // so a change on either platform automatically propagates. - // Android-only extras (badge awards, git issues/patches/PRs, - // highlights, polls, videos, voice, public messages, - // live-activities chat) stay here because Desktop has no - // rendering for those kinds today. - com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationKinds - .SUBSCRIPTION_KINDS - .toSet() + - setOf( - BadgeAwardEvent.KIND, - GitIssueEvent.KIND, - GitPatchEvent.KIND, - GitPullRequestEvent.KIND, - GitPullRequestUpdateEvent.KIND, - HighlightEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, - PictureEvent.KIND, - PollEvent.KIND, - ZapPollEvent.KIND, - PublicMessageEvent.KIND, - VideoNormalEvent.KIND, - VideoShortEvent.KIND, - VoiceEvent.KIND, - VoiceReplyEvent.KIND, - ) + ADDRESSABLE_KINDS + // Kinds that RENDER as a row on the Notifications tab. This is a + // display gate over whatever is already in LocalCache — it plays no + // part in relay subscriptions (those live in + // FilterNotificationsToPubkey, the chat datasources, and the wallet + // assembler). It deliberately does NOT share Desktop's + // `NotificationKinds.SUBSCRIPTION_KINDS`: that list answers "what to + // ask relays for / toast on" and includes envelope kinds like + // GiftWrap (1059), whose created_at is randomized up to 2 days back + // (NIP-59). Envelopes never render here — the unwrapped inner event + // (kind 14/15/…) is the feed row, same rule NotificationDispatcher + // applies to push. NotificationKindsContractTest pins the + // relationship between the two lists. + setOf( + BadgeAwardEvent.KIND, + ChannelMessageEvent.KIND, + ChatMessageEvent.KIND, + ChatMessageEncryptedFileHeaderEvent.KIND, + CommentEvent.KIND, + GenericRepostEvent.KIND, + GitIssueEvent.KIND, + GitPatchEvent.KIND, + GitPullRequestEvent.KIND, + GitPullRequestUpdateEvent.KIND, + HighlightEvent.KIND, + TextNoteEvent.KIND, + ReactionEvent.KIND, + RepostEvent.KIND, + LnZapEvent.KIND, + NutzapEvent.KIND, + OnchainZapEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + PictureEvent.KIND, + PollEvent.KIND, + ZapPollEvent.KIND, + PrivateDmEvent.KIND, + PublicMessageEvent.KIND, + VideoNormalEvent.KIND, + VideoShortEvent.KIND, + VoiceEvent.KIND, + VoiceReplyEvent.KIND, + ) + ADDRESSABLE_KINDS // How deep to walk a public chat reply chain looking for one of the // user's own messages. Bounds the cost on very long threads; the 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 a97e2068da..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 @@ -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?, @@ -633,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/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index fd9a2d1d5f..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 @@ -113,6 +113,10 @@ fun DrawAdditionalInfo( 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, 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..a09f1d78bb --- /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 = 14.dp, bottom = 3.5.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/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..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 @@ -22,16 +22,20 @@ 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 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 @@ -45,6 +49,19 @@ fun UserProfileDropDownMenu( onDismiss: () -> Unit, accountViewModel: AccountViewModel, ) { + 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 }, + ) + } + if (!popupExpanded) return M3ActionDialog( @@ -88,6 +105,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-ar-rSA/strings.xml b/amethyst/src/main/res/values-ar-rSA/strings.xml index fe16a886ea..920e74aecc 100644 --- a/amethyst/src/main/res/values-ar-rSA/strings.xml +++ b/amethyst/src/main/res/values-ar-rSA/strings.xml @@ -2823,6 +2823,7 @@ إجراءات الوسائط التشغيل تلقائي + البث إلى جهاز إيقاف البث diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml index edc69f1593..399854cf01 100644 --- a/amethyst/src/main/res/values-bn-rBD/strings.xml +++ b/amethyst/src/main/res/values-bn-rBD/strings.xml @@ -2702,6 +2702,7 @@ মিডিয়া অ্যাকশন প্লেব্যাক স্বয়ংক্রিয় + ডিভাইসে কাস্ট করুন কাস্টিং বন্ধ করুন diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index f9a50c85e7..706a9fdc4b 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -3301,6 +3301,7 @@ Akce médií Přehrávání Auto + Odeslat do zařízení Ukončit odesílání diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 090be920d0..c8903d7063 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -3169,6 +3169,7 @@ Medienaktionen Wiedergabe Auto + An Gerät streamen Streaming beenden diff --git a/amethyst/src/main/res/values-el-rGR/strings.xml b/amethyst/src/main/res/values-el-rGR/strings.xml index e989bff3a5..2844d47390 100644 --- a/amethyst/src/main/res/values-el-rGR/strings.xml +++ b/amethyst/src/main/res/values-el-rGR/strings.xml @@ -2647,6 +2647,7 @@ Ενέργειες Πολυμέσων Αναπαραγωγή Αυτόματο + Μετάδοση σε συσκευή Διακοπή μετάδοσης diff --git a/amethyst/src/main/res/values-en-rGB/strings.xml b/amethyst/src/main/res/values-en-rGB/strings.xml index 132d63dc73..eda9fdf794 100644 --- a/amethyst/src/main/res/values-en-rGB/strings.xml +++ b/amethyst/src/main/res/values-en-rGB/strings.xml @@ -30,6 +30,7 @@ + diff --git a/amethyst/src/main/res/values-eo-rUY/strings.xml b/amethyst/src/main/res/values-eo-rUY/strings.xml index ce414fabc8..418d68e31c 100644 --- a/amethyst/src/main/res/values-eo-rUY/strings.xml +++ b/amethyst/src/main/res/values-eo-rUY/strings.xml @@ -2704,6 +2704,7 @@ Mediaj Agoj Reproduktado Aŭtomata + Transigi al aparato Ĉesi transiĝon diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index 4243cf8549..b8ce45f477 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -2770,6 +2770,7 @@ Acciones de medios Reproducción Automático + Emitir al dispositivo Detener emisión diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index 6df3bf7d0f..d57b90ddfe 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -2761,6 +2761,7 @@ Acciones de medios Reproducción Automático + Emitir al dispositivo Detener emisión diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index 05a196bd7d..d83d1cbde8 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -2761,6 +2761,7 @@ Acciones de medios Reproducción Automático + Emitir al dispositivo Detener emisión diff --git a/amethyst/src/main/res/values-fa-rIR/strings.xml b/amethyst/src/main/res/values-fa-rIR/strings.xml index 10dad02bfb..8ffe8921b4 100644 --- a/amethyst/src/main/res/values-fa-rIR/strings.xml +++ b/amethyst/src/main/res/values-fa-rIR/strings.xml @@ -2718,6 +2718,7 @@ اقدامات رسانه پخش خودکار + پخش روی دستگاه توقف پخش diff --git a/amethyst/src/main/res/values-fi-rFI/strings.xml b/amethyst/src/main/res/values-fi-rFI/strings.xml index 7ce8e75691..c2256d196a 100644 --- a/amethyst/src/main/res/values-fi-rFI/strings.xml +++ b/amethyst/src/main/res/values-fi-rFI/strings.xml @@ -2678,6 +2678,7 @@ Median toiminnot Toisto Automaattinen + Suoratoista laitteeseen Lopeta suoratoisto diff --git a/amethyst/src/main/res/values-fr-rCA/strings.xml b/amethyst/src/main/res/values-fr-rCA/strings.xml index 9a98dbfd52..5cfeafd164 100644 --- a/amethyst/src/main/res/values-fr-rCA/strings.xml +++ b/amethyst/src/main/res/values-fr-rCA/strings.xml @@ -2606,6 +2606,7 @@ Actions sur le profil Actions sur le média Lecture + Diffuser sur l\'appareil Arrêter la diffusion diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index 5caec58f87..89536d9bac 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -2983,6 +2983,7 @@ Actions sur le média Lecture Auto + Diffuser sur l\'appareil Arrêter la diffusion diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 90d73c13a3..e096ff2974 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -3216,6 +3216,7 @@ अभिलेख कार्य चालन स्वचालित + यन्त्र पर निक्षेपण निक्षेपण रोकें diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index c667c9f116..c451b2e909 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -3217,6 +3217,7 @@ Médiaműveletek Lejátszás Automatikus + Közvetítés eszközre Közvetítés leállítása diff --git a/amethyst/src/main/res/values-in-rID/strings.xml b/amethyst/src/main/res/values-in-rID/strings.xml index 55c2791a6f..866eac29d9 100644 --- a/amethyst/src/main/res/values-in-rID/strings.xml +++ b/amethyst/src/main/res/values-in-rID/strings.xml @@ -2606,6 +2606,7 @@ Seharusnya %3$s Tindakan Media Pemutaran Otomatis + Tampilkan ke perangkat Hentikan penayangan diff --git a/amethyst/src/main/res/values-it-rIT/strings.xml b/amethyst/src/main/res/values-it-rIT/strings.xml index 11279feaef..f0d0a2a7b5 100644 --- a/amethyst/src/main/res/values-it-rIT/strings.xml +++ b/amethyst/src/main/res/values-it-rIT/strings.xml @@ -2645,6 +2645,7 @@ Azioni profilo Azioni media Riproduzione + Trasmetti al dispositivo Interrompi trasmissione diff --git a/amethyst/src/main/res/values-ja-rJP/strings.xml b/amethyst/src/main/res/values-ja-rJP/strings.xml index 033213ceb3..5d772688c6 100644 --- a/amethyst/src/main/res/values-ja-rJP/strings.xml +++ b/amethyst/src/main/res/values-ja-rJP/strings.xml @@ -2660,6 +2660,7 @@ メディアのアクション 再生 自動 + デバイスにキャスト キャストを停止 diff --git a/amethyst/src/main/res/values-ko-rKR/strings.xml b/amethyst/src/main/res/values-ko-rKR/strings.xml index d513de6d20..2c350714e7 100644 --- a/amethyst/src/main/res/values-ko-rKR/strings.xml +++ b/amethyst/src/main/res/values-ko-rKR/strings.xml @@ -2647,6 +2647,7 @@ 미디어 작업 재생 자동 + 기기로 캐스트 캐스트 중지 diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml index 567924d2d6..947c7a7461 100644 --- a/amethyst/src/main/res/values-lv-rLV/strings.xml +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -2720,6 +2720,7 @@ Multivides darbības Atskaņošana Automātiski + Apraidīt uz ierīci Pārtraukt apraidi diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index 48933a0fb3..88635bb0a0 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -3052,6 +3052,7 @@ Media-acties Afspelen Auto + Casten naar apparaat Casten stoppen diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index cf336b07d8..9bc2d19653 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -3302,6 +3302,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Akcje multimediów Odtwarzanie Automatycznie + Zrzuć na urządzenie Przestań zrzucać diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 2a7f29143b..8f23756f3a 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -3165,6 +3165,7 @@ Ações de mídia Reprodução Auto + Transmitir para dispositivo Parar transmissão diff --git a/amethyst/src/main/res/values-pt-rPT/strings.xml b/amethyst/src/main/res/values-pt-rPT/strings.xml index d06e8cbdb8..77946bedcb 100644 --- a/amethyst/src/main/res/values-pt-rPT/strings.xml +++ b/amethyst/src/main/res/values-pt-rPT/strings.xml @@ -2664,6 +2664,7 @@ Ações do perfil Ações de mídia Reprodução + Transmitir para dispositivo Parar transmissão diff --git a/amethyst/src/main/res/values-ru-rRU/strings.xml b/amethyst/src/main/res/values-ru-rRU/strings.xml index 541080ba25..ce0893cfb8 100644 --- a/amethyst/src/main/res/values-ru-rRU/strings.xml +++ b/amethyst/src/main/res/values-ru-rRU/strings.xml @@ -2742,6 +2742,7 @@ Действия с медиа Воспроизведение Авто + Трансляция на устройство Остановить трансляцию diff --git a/amethyst/src/main/res/values-ru-rUA/strings.xml b/amethyst/src/main/res/values-ru-rUA/strings.xml index 63c1339889..9f41ce3df0 100644 --- a/amethyst/src/main/res/values-ru-rUA/strings.xml +++ b/amethyst/src/main/res/values-ru-rUA/strings.xml @@ -2725,6 +2725,7 @@ Действия с медиа Воспроизведение Авто + Трансляция на устройство Остановить трансляцию diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 7470dcfef9..7dd53b26e3 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -3317,6 +3317,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Možnosti predstavnosti Predvajaj Samodejno + Prenesi v napravo Prenehaj s prenašanjem diff --git a/amethyst/src/main/res/values-sr-rSP/strings.xml b/amethyst/src/main/res/values-sr-rSP/strings.xml index b068cb0758..49b7601369 100644 --- a/amethyst/src/main/res/values-sr-rSP/strings.xml +++ b/amethyst/src/main/res/values-sr-rSP/strings.xml @@ -2703,6 +2703,7 @@ Radnje sa medijima Reprodukcija Automatski + Prikaži na uređaju Zaustavi prikazivanje diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 83a1991f8d..6bbc9cff50 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -3173,6 +3173,7 @@ Medieåtgärder Uppspelning Auto + Casta till enhet Sluta casta diff --git a/amethyst/src/main/res/values-sw/strings.xml b/amethyst/src/main/res/values-sw/strings.xml index fda33de346..c1758eb485 100644 --- a/amethyst/src/main/res/values-sw/strings.xml +++ b/amethyst/src/main/res/values-sw/strings.xml @@ -2684,6 +2684,7 @@ Vitendo vya Midia Uchezaji Otomatiki + Tuma kwenye kifaa Acha kutuma diff --git a/amethyst/src/main/res/values-ta-rIN/strings.xml b/amethyst/src/main/res/values-ta-rIN/strings.xml index ac6c3e80b9..e77b264334 100644 --- a/amethyst/src/main/res/values-ta-rIN/strings.xml +++ b/amethyst/src/main/res/values-ta-rIN/strings.xml @@ -2673,6 +2673,7 @@ ஊடக செயல்கள் இயக்கம் தானியங்கி + சாதனத்திற்கு அனுப்பவும் அனுப்புவதை நிறுத்தவும் diff --git a/amethyst/src/main/res/values-th-rTH/strings.xml b/amethyst/src/main/res/values-th-rTH/strings.xml index 7be1e93544..dcb597e18d 100644 --- a/amethyst/src/main/res/values-th-rTH/strings.xml +++ b/amethyst/src/main/res/values-th-rTH/strings.xml @@ -2512,6 +2512,7 @@ การดำเนินการกับสื่อ การเล่น อัตโนมัติ + แคสต์ไปยังอุปกรณ์ หยุดการแคสต์ diff --git a/amethyst/src/main/res/values-tr-rTR/strings.xml b/amethyst/src/main/res/values-tr-rTR/strings.xml index b3b34cb392..c1ebcd1dff 100644 --- a/amethyst/src/main/res/values-tr-rTR/strings.xml +++ b/amethyst/src/main/res/values-tr-rTR/strings.xml @@ -2685,6 +2685,7 @@ Medya İşlemleri Oynatma Otomatik + Cihaza yayınla Yayını durdur diff --git a/amethyst/src/main/res/values-uk-rUA/strings.xml b/amethyst/src/main/res/values-uk-rUA/strings.xml index fe1cde2fcf..b6211b0f0d 100644 --- a/amethyst/src/main/res/values-uk-rUA/strings.xml +++ b/amethyst/src/main/res/values-uk-rUA/strings.xml @@ -2733,6 +2733,7 @@ Дії з медіа Відтворення Авто + Трансляція на пристрій Зупинити трансляцію diff --git a/amethyst/src/main/res/values-uz-rUZ/strings.xml b/amethyst/src/main/res/values-uz-rUZ/strings.xml index 5e41ccb79e..6fe092d19e 100644 --- a/amethyst/src/main/res/values-uz-rUZ/strings.xml +++ b/amethyst/src/main/res/values-uz-rUZ/strings.xml @@ -2689,6 +2689,7 @@ Media amallar Ijro etish Avtomatik + Qurilmaga uzatish Uzatishni to\'xtatish diff --git a/amethyst/src/main/res/values-vi-rVN/strings.xml b/amethyst/src/main/res/values-vi-rVN/strings.xml index d153949ae2..9e72b7f0ad 100644 --- a/amethyst/src/main/res/values-vi-rVN/strings.xml +++ b/amethyst/src/main/res/values-vi-rVN/strings.xml @@ -2630,6 +2630,7 @@ Thao tác phương tiện Phát lại Tự động + Chiếu đến thiết bị Dừng chiếu diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 3ff2b4c951..3e56dab2f5 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -1184,6 +1184,8 @@ 尚无接收方:只有您才能看到此笔记。添加分享对象。 添加 从通知中删除用户 + 通知中。轻触静音此用户的通知 + 已静音。轻触再次通知此用户 搜索并添加要通知的用户 在这里不是书签 从列表中删除书签 @@ -3172,6 +3174,7 @@ 媒体操作 播放 自动 + 投射到设备 停止投影 diff --git a/amethyst/src/main/res/values-zh-rHK/strings.xml b/amethyst/src/main/res/values-zh-rHK/strings.xml index fea511090e..21cb66c936 100644 --- a/amethyst/src/main/res/values-zh-rHK/strings.xml +++ b/amethyst/src/main/res/values-zh-rHK/strings.xml @@ -2700,6 +2700,7 @@ 媒体操作 播放 自动 + 投射到设备 停止投影 diff --git a/amethyst/src/main/res/values-zh-rSG/strings.xml b/amethyst/src/main/res/values-zh-rSG/strings.xml index 4d2541c844..65bb95dfd3 100644 --- a/amethyst/src/main/res/values-zh-rSG/strings.xml +++ b/amethyst/src/main/res/values-zh-rSG/strings.xml @@ -2700,6 +2700,7 @@ 媒体操作 播放 自动 + 投射到设备 停止投影 diff --git a/amethyst/src/main/res/values-zh-rTW/strings.xml b/amethyst/src/main/res/values-zh-rTW/strings.xml index 8feb85b6b3..98a024b0ee 100644 --- a/amethyst/src/main/res/values-zh-rTW/strings.xml +++ b/amethyst/src/main/res/values-zh-rTW/strings.xml @@ -2665,6 +2665,7 @@ 媒體操作 播放 自動 + 投放至裝置 停止投放 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 4eb178ccc4..ce8b6131e3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3611,6 +3611,10 @@ Playback Auto + + Edit nickname + Only visible to you + Cast to device Stop casting diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationKindsContractTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationKindsContractTest.kt new file mode 100644 index 0000000000..0e49022cf9 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationKindsContractTest.kt @@ -0,0 +1,76 @@ +/* + * 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.notifications.dal + +import com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationKinds +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins the relationship between the two independently-maintained kind lists: + * + * - `NotificationKinds.SUBSCRIPTION_KINDS` (commons) — what Desktop asks + * relays for and toasts on. May contain ENVELOPE kinds (gift wraps) whose + * created_at is randomized per NIP-59, because Desktop surfaces the wrap + * itself as a DM row. + * - `NotificationFeedFilter.NOTIFICATION_KINDS` (Android) — what renders as + * a row on the Notifications tab. Envelopes must never appear here; the + * unwrapped inner event is the row. + * + * The lists were briefly coupled (NOTIFICATION_KINDS spread + * SUBSCRIPTION_KINDS), which silently pulled the kind-1059 wrap into the + * Android feed. They are now maintained separately; this test is the tripwire + * that keeps them from drifting apart unintentionally in either direction. + */ +class NotificationKindsContractTest { + private val envelopeKinds = setOf(GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND) + + @Test + fun `envelope kinds never render on the Android notifications tab`() { + val leaked = envelopeKinds.intersect(NotificationFeedFilter.NOTIFICATION_KINDS.toSet()) + assertTrue( + "Envelope kinds $leaked are in NOTIFICATION_KINDS. Wraps have a " + + "randomized created_at (NIP-59) and no decryptable payload to render — " + + "the unwrapped inner event is the feed row. If a new envelope kind is " + + "intentional, unwrap it instead of displaying it.", + leaked.isEmpty(), + ) + } + + @Test + fun `every kind desktop notifies on is displayable on Android or a known envelope`() { + val unaccounted = + NotificationKinds.SUBSCRIPTION_KINDS.toSet() - + NotificationFeedFilter.NOTIFICATION_KINDS.toSet() - + envelopeKinds + + assertTrue( + "Kinds $unaccounted were added to the shared SUBSCRIPTION_KINDS but are " + + "neither displayable on the Android notifications tab nor a known " + + "envelope kind. Either add them to NOTIFICATION_KINDS (if Android " + + "should render them) or to envelopeKinds in this test (if they only " + + "deliver an inner payload).", + unaccounted.isEmpty(), + ) + } +} 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/EmojiPackState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt index f8f77d36c9..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 @@ -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,23 @@ 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() + val byCode = myEmojiSet.associateBy { it.code } + return CustomEmoji + .findAllEmojiCodes(message) + .distinct() + .mapNotNull { code -> + byCode[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/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 73% 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..0f5e457153 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,30 @@ * 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.foundation.text.input.TextFieldState import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState -import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord 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 @@ -63,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/ContactCardDecryptionCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt new file mode 100644 index 0000000000..e6e5477791 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt @@ -0,0 +1,65 @@ +/* + * 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.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 +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) + + suspend fun petName(event: ContactCardEvent) = cachedPrivateCards.mergeTagList(event).petName() + + suspend fun summary(event: ContactCardEvent) = cachedPrivateCards.mergeTagList(event).summary() + + /** + * 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 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 cachedNickname(event: ContactCardEvent): Nickname? = cachedPrivateCards.mergeTagListPrecached(event).toNickname() + + 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 new file mode 100644 index 0000000000..f643429c5c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt @@ -0,0 +1,171 @@ +/* + * 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.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.nip85TrustedAssertions.users.ContactCardEvent +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 +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 emojiPacks: EmojiPackState, +) { + 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. `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 = + target + .cards() + .receivedCards + .map { it[accountUser] } + .distinctUntilChanged() + .flatMapLatest { note -> + note + ?.flow() + ?.metadata + ?.stateFlow + ?.map { it.note.event as? ContactCardEvent } + ?: flowOf(null) + } + + /** + * 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 nicknameFlow(target: User): Flow = + myCardFlow(target) + .mapLatest { card -> card?.let { decryptionCache.nickname(it) } } + .distinctUntilChanged() + .flowOn(Dispatchers.IO) + + /** + * 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 cachedNickname(target: User): Nickname? { + val card = + target + .cardsOrNull() + ?.receivedCards + ?.value + ?.get(accountUser) + ?.event as? ContactCardEvent ?: return null + return decryptionCache.cachedNickname(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, + 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 = cachedNickname(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) } + + /** + * 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?, + ): ContactCardEvent { + val emojis = emojiPacks.findEmojiTags(listOfNotNull(petName, summary).joinToString(" ")) + val existing = getCard(target) + return if (existing != null) { + 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, + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/Nickname.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/Nickname.kt new file mode 100644 index 0000000000..1d678013a8 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/Nickname.kt @@ -0,0 +1,48 @@ +/* + * 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 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 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 Nickname && + petName == other.petName && + summary == other.summary && + tags.lists.contentDeepEquals(other.tags.lists) + + override fun hashCode(): Int = 31 * (31 * petName.hashCode() + summary.hashCode()) + tags.contentHash() +} 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 84% 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 b6e6ec3e8a..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,14 +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.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.model.nip30CustomEmojis.EmojiSuggestionState +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( @@ -72,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/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip85TrustedAssertions/ui/EditNicknameDialog.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip85TrustedAssertions/ui/EditNicknameDialog.kt new file mode 100644 index 0000000000..b24c98cbb5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip85TrustedAssertions/ui/EditNicknameDialog.kt @@ -0,0 +1,172 @@ +/* + * 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.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.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 kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +/** + * 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. + * + * 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, +) { + val nickname = rememberTextFieldState() + val summary = rememberTextFieldState() + val emojiSuggestions = remember(contactCards) { EmojiSuggestionState(contactCards.emojiPacks) } + // which field the emoji autocomplete should insert into: the last one edited + val emojiTarget = remember { mutableStateOf(null) } + + // 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) { + contactCards + .petName(user.pubkeyHex) + ?.takeIf { nickname.text.isEmpty() } + ?.let { nickname.setTextAndPlaceCursorAtEnd(it) } + contactCards + .summary(user.pubkeyHex) + ?.takeIf { summary.text.isEmpty() } + ?.let { summary.setTextAndPlaceCursorAtEnd(it) } + } + + // 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 = stringResource(Res.string.nickname_dialog_title)) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = stringResource(Res.string.nickname_dialog_explainer), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + state = nickname, + lineLimits = TextFieldLineLimits.SingleLine, + label = { + Text(text = stringResource(Res.string.nickname_label)) + }, + ) + OutlinedTextField( + state = summary, + label = { + Text(text = stringResource(Res.string.nickname_summary_label)) + }, + ) + ShowEmojiSuggestionList( + emojiSuggestions, + onSelect = { emoji -> + emojiTarget.value?.let { emojiSuggestions.autocompleteInto(it, emoji) } + }, + onFullSize = { emoji -> + emojiTarget.value?.let { emojiSuggestions.autocompleteInto(it, emoji) } + }, + modifier = Modifier.heightIn(max = 200.dp), + ) + } + }, + confirmButton = { + Button( + onClick = { + onSave( + nickname.text + .toString() + .trim() + .ifBlank { null }, + summary.text + .toString() + .trim() + .ifBlank { null }, + ) + onDismiss() + }, + ) { + Text(stringResource(Res.string.nickname_save)) + } + }, + dismissButton = { + Button( + onClick = onDismiss, + ) { + 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 65% 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..9eab40c51a 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,16 +18,22 @@ * 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 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) +/** + * 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, @@ -41,8 +47,31 @@ 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, ), ) } + +/** + * 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, + ), + ) 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..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,31 +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.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.nip51Lists.remove 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 @@ -68,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 @@ -123,26 +112,90 @@ 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() + } + } + + /** + * 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, + petName: String? = null, + summary: String? = null, + emojis: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): EventTemplate { + val privateTags = + earlierVersion.privateTags(signer) + ?: throw SignerExceptions.UnauthorizedDecryptionException() + + var newPrivateTags = + 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 + .remove(arrayOf(PetNameTag.TAG_NAME)) + .remove(arrayOf(SummaryTag.TAG_NAME)) + + 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 new file mode 100644 index 0000000000..fad3a43cc8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayExt.kt @@ -0,0 +1,81 @@ +/* + * 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.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) + +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..17d1f2d14b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt @@ -0,0 +1,185 @@ +/* + * 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.nip30CustomEmoji.EmojiUrlTag +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 = + signer.sign( + 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 = + signer.sign( + ContactCardEvent.updatePetNameAndSummary( + earlierVersion = card, + petName = null, + summary = null, + signer = signer, + ), + ) + + assertNull(cleared.privatePetName()) + 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:", + emojis = listOf(oldEmoji), + signer = signer, + ) + assertEquals(listOf(oldEmoji), card.privateTags(signer)!!.mapNotNull(EmojiUrlTag::parse)) + + val updated = + signer.sign( + 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 { + // 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 = + signer.sign( + ContactCardEvent.updatePetNameAndSummary( + earlierVersion = card, + petName = "private bob", + signer = signer, + ), + ) + + assertNull(updated.petName()) + assertNull(updated.summary()) + assertEquals("private bob", updated.privatePetName()) + } +}