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 c374cc6d95..25e869c8f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -45,6 +45,8 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecr import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDecryptionCache +import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardDecryptionCache +import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult @@ -478,6 +480,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) @@ -3814,6 +3820,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/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 28b4f7b4f5..673e6cba60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata +import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterContactCardsByAuthorInTheRelay import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent @@ -124,6 +125,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 e87ede251a..4423e58a9c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.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 @@ -298,14 +299,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 183a5395ea..54e13bb3d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -105,6 +105,7 @@ import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon import com.vitorpamplona.amethyst.service.CachedRichTextParser import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.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 @@ -1010,15 +1011,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 8e06823ba4..b554cf1a71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1704,6 +1704,12 @@ class AccountViewModel( fun hide(user: User) = launchSigner { account.hideUser(user.pubkeyHex) } + fun updateContactCardPetName( + user: User, + petName: String?, + summary: String?, + ) = launchSigner { account.updateContactCardPetName(user.pubkeyHex, petName, summary) } + fun hide(word: String) = launchSigner { account.hideWord(word) } fun showUser(pubkeyHex: String) = launchSigner { account.showUser(pubkeyHex) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt index 2488506058..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 fe79dde482..377b424bbb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay @@ -56,7 +57,6 @@ import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState @@ -88,8 +88,6 @@ import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip28PublicChat.base.notify import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip29RelayGroups.hTag -import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason @@ -212,7 +210,7 @@ open class ChannelNewMessageViewModel : ) this.emojiSuggestions?.reset() - this.emojiSuggestions = EmojiSuggestionState(accountVM.account) + this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji) this.uploadState = ChatFileUploadState(account.settings.defaultFileServer, account.settings.stripLocationOnUpload) } @@ -433,7 +431,7 @@ open class ChannelNewMessageViewModel : val urls = findURLs(messageText) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) - val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value) + val emojis = accountViewModel.account.emoji.findEmojiTags(messageText) val channelRelays = channel.relays() val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null @@ -597,16 +595,6 @@ open class ChannelNewMessageViewModel : } } - fun findEmoji( - message: String, - myEmojiSet: List?, - ): List { - if (myEmojiSet == null) return emptyList() - return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> - myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) } - } - } - open fun cancel() { draftTag.rotate() @@ -690,10 +678,7 @@ open class ChannelNewMessageViewModel : } open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { - val wordToInsert = ":${item.code}:" - message.replaceCurrentWord(wordToInsert) - - emojiSuggestions?.reset() + emojiSuggestions?.autocompleteInto(message, item) draftTag.newVersion() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index e9f150b50a..f23c35fce7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation @@ -44,7 +45,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index 95cdc5577c..93c8a6b0ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -83,6 +83,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation @@ -99,7 +100,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index 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/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/strings.xml b/amethyst/src/main/res/values/strings.xml index 4f1f0685b0..68e69ecd74 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3545,6 +3545,10 @@ Playback Auto + + Edit nickname + Only visible to you + Cast to device Stop casting 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()) + } +}