refactor: move nickname policy, emoji autocomplete, filters and dialog to commons

Generalizes the remaining platform-bound pieces of the nickname feature so
the desktop app can adopt it:

- ContactCardsState.displayNameFlow/cachedDisplayName own the NIP-81 render
  policy (petname over profile name over npub); the Android observeUserName
  is now a thin wrapper around it
- EmojiSuggestionState.autocompleteInto completes the word under the cursor
  and closes the list — replaces the insert+reset pair copy-pasted in the 8
  composer ViewModels and the nickname dialog
- the kind:30382 filter builders move to commons relayClient/assemblers
  (cards about targets from trusted accounts, and the account's own cards by
  author), shared by the user watcher and the login subscription
- ShowEmojiSuggestionList moves to commons nip30CustomEmojis/ui using coil
  and commons string resources
- EditNicknameDialog moves to commons nip85TrustedAssertions/ui: it takes the
  ContactCardsState and an onSave callback, so each front end only wires its
  own publish path and menu entry; dialog strings move to commons resources

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
This commit is contained in:
Claude
2026-07-13 21:20:36 +00:00
parent b69304c4a8
commit e8ecf429a2
28 changed files with 175 additions and 147 deletions
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterContactCardsByAuthorInTheRelay
import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
@@ -49,7 +50,6 @@ import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
@@ -94,12 +94,6 @@ val AccountInfoAndListsFromKeyKinds2 =
NutzapInfoEvent.KIND,
)
// The account's own kind:30382 contact cards (nicknames/petnames for other
// users, NIP-44 encrypted). Addressable: one card per target user, so this is a
// collection rather than a single replaceable — kept out of the small-limit
// filters above and given its own filter with a larger limit.
val AccountContactCardKinds = listOf(ContactCardEvent.KIND)
val AmethystMetadataKinds = listOf(AppSpecificDataEvent.KIND)
val AmethystMetadataTagMapFilter = mapOf("d" to listOf(APP_SPECIFIC_DATA_D_TAG))
@@ -131,15 +125,12 @@ fun filterAccountInfoAndListsFromKey(
since = since,
),
),
RelayBasedFilter(
// The account's own kind:30382 contact cards (nicknames, NIP-44 encrypted).
// Addressable — one card per target user — hence its own larger-limit filter.
filterContactCardsByAuthorInTheRelay(
relay = relay,
filter =
Filter(
kinds = AccountContactCardKinds,
authors = listOf(pubkey),
limit = 500,
since = since,
),
author = pubkey,
since = since,
),
RelayBasedFilter(
relay = relay,
@@ -61,25 +61,11 @@ fun observeUserName(
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
val flow =
remember(user) {
combine(
user.metadata().flow,
accountViewModel.account.contactCards.petNameFlow(user),
) { info, petName ->
petName?.petName ?: info?.info?.bestName() ?: user.pubkeyDisplayHex()
}.distinctUntilChanged()
}
val initialName =
remember(user) {
accountViewModel.account.contactCards
.cachedPetName(user)
?.petName ?: user.toBestDisplayName()
}
val contactCards = accountViewModel.account.contactCards
val flow = remember(user) { contactCards.displayNameFlow(user) }
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle(initialName)
return flow.collectAsStateWithLifecycle(remember(user) { contactCards.cachedDisplayName(user) })
}
/**
@@ -323,7 +309,7 @@ fun observeUserBookmarkCount(
val combined =
remember(user) {
kotlinx.coroutines.flow.combine(newFlow, oldFlow) { newCount, oldCount ->
combine(newFlow, oldFlow) { newCount, oldCount ->
newCount + oldCount
}
}
@@ -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
@@ -920,13 +920,9 @@ open class CommentPostViewModel :
}
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.autocompleteInto(message, item)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
draftTag.newVersion()
}
@@ -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
@@ -764,13 +764,9 @@ class ChatNewMessageViewModel :
}
fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.autocompleteInto(message, item)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
draftTag.newVersion()
}
@@ -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
@@ -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
@@ -678,10 +678,7 @@ open class ChannelNewMessageViewModel :
}
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.reset()
emojiSuggestions?.autocompleteInto(message, item)
draftTag.newVersion()
}
@@ -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
@@ -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
@@ -692,9 +692,7 @@ class LongFormPostViewModel :
}
fun autocompleteWithEmoji(item: EmojiMedia) {
val wordToInsert = ":${item.code}:"
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.reset()
emojiSuggestions?.autocompleteInto(message, item)
draftTag.newVersion()
}
@@ -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
@@ -558,13 +558,9 @@ open class NewProductViewModel :
}
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.autocompleteInto(message, item)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
draftTag.newVersion()
}
@@ -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
@@ -1610,13 +1610,9 @@ open class ShortNotePostViewModel :
}
open fun autocompleteWithEmoji(item: EmojiMedia) {
val wordToInsert = ":${item.code}:"
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.autocompleteInto(message, item)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
draftTag.newVersion()
}
@@ -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
@@ -548,10 +548,7 @@ open class NestNewMessageViewModel :
}
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.reset()
emojiSuggestions?.autocompleteInto(message, item)
draftTag.newVersion()
}
@@ -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
@@ -621,13 +621,9 @@ class NewPublicMessageViewModel :
}
fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.autocompleteInto(message, item)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
draftTag.newVersion()
}
@@ -29,11 +29,13 @@ import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.nip85TrustedAssertions.ui.EditNicknameDialog
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
import com.vitorpamplona.amethyst.ui.note.externalLinkForUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -50,10 +52,13 @@ fun UserProfileDropDownMenu(
val isNicknameDialogOpen = remember { mutableStateOf(false) }
if (isNicknameDialogOpen.value) {
// keeps the account's selected emoji packs loaded for the : autocomplete
WatchAndLoadMyEmojiList(accountViewModel)
EditNicknameDialog(
user = user,
contactCards = accountViewModel.account.contactCards,
onSave = { petName, summary -> accountViewModel.updateContactCardPetName(user, petName, summary) },
onDismiss = { isNicknameDialogOpen.value = false },
accountViewModel = accountViewModel,
)
}
+1 -5
View File
@@ -3543,12 +3543,8 @@
<string name="playback_actions_dialog_title">Playback</string>
<string name="video_quality_auto">Auto</string>
<!-- Nicknames (NIP-85 contact cards) -->
<!-- Nicknames (NIP-85 contact cards); the dialog strings live in commons -->
<string name="edit_nickname">Edit nickname</string>
<string name="nickname_dialog_title">Nickname</string>
<string name="nickname_dialog_explainer">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.</string>
<string name="nickname_label">Nickname</string>
<string name="nickname_summary_label">Private note about this user</string>
<!-- LAN cast (Chromecast) feature -->
<string name="cast_to_device">Cast to device</string>
@@ -90,4 +90,15 @@
<string name="nsite_source">Source:</string>
<string name="nsite_servers">Servers:</string>
<string name="nsite_open">Open</string>
<!-- Custom emoji suggestions (NIP-30) -->
<string name="use_direct_url">Use direct URL</string>
<!-- Nicknames (NIP-85 contact cards) -->
<string name="nickname_dialog_title">Nickname</string>
<string name="nickname_dialog_explainer">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.</string>
<string name="nickname_label">Nickname</string>
<string name="nickname_summary_label">Private note about this user</string>
<string name="nickname_save">Save</string>
<string name="nickname_cancel">Cancel</string>
</resources>
@@ -20,7 +20,9 @@
*/
package com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow
@@ -66,4 +68,16 @@ class EmojiSuggestionState(
}
}
}
/**
* Completes the word under the cursor in [field] with the selected emoji's
* `:shortcode:` and closes the suggestion list.
*/
fun autocompleteInto(
field: TextFieldState,
item: EmojiPackState.EmojiMedia,
) {
field.replaceCurrentWord(":${item.code}:")
reset()
}
}
@@ -33,6 +33,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
@@ -112,6 +113,22 @@ class ContactCardsState(
return decryptionCache.cachedPetNameWithEmojis(card)
}
/**
* The name to render for [target], per the NIP-81 policy: the nickname the
* account gave them wins over the profile's own display name, falling back
* to the short npub when neither exists.
*/
fun displayNameFlow(target: User): Flow<String> =
combine(
target.metadata().flow,
petNameFlow(target),
) { info, petName ->
petName?.petName ?: info?.info?.bestName() ?: target.pubkeyDisplayHex()
}.distinctUntilChanged()
/** Synchronous first value for [displayNameFlow], from already-decrypted data. */
fun cachedDisplayName(target: User): String = cachedPetName(target)?.petName ?: target.toBestDisplayName()
suspend fun petName(target: HexKey): String? = getCard(target)?.let { decryptionCache.petName(it) }
suspend fun summary(target: HexKey): String? = getCard(target)?.let { decryptionCache.summary(it) }
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions
package com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.spacedBy
@@ -39,15 +39,17 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size40Modifier
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.use_direct_url
import org.jetbrains.compose.resources.stringResource
private val DividerThickness = 0.25.dp
private val RowSpacing = 10.dp
private val EmojiSize = Modifier.size(40.dp)
@Composable
fun ShowEmojiSuggestionList(
@@ -73,23 +75,23 @@ fun ShowEmojiSuggestionList(
bottom = 10.dp,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = spacedBy(Size10dp),
horizontalArrangement = spacedBy(RowSpacing),
) {
AsyncImage(
it.link,
contentDescription = it.code,
modifier = Size40Modifier,
modifier = EmojiSize,
)
Text(it.code, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
IconButton(
modifier = Size40Modifier,
modifier = EmojiSize,
onClick = {
onFullSize(it)
},
) {
Icon(
symbol = MaterialSymbols.OpenInFull,
contentDescription = stringRes(R.string.use_direct_url),
contentDescription = stringResource(Res.string.use_direct_url),
modifier = Modifier.size(20.dp),
)
}
@@ -18,35 +18,41 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header
package com.vitorpamplona.amethyst.commons.nip85TrustedAssertions.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.nickname_cancel
import com.vitorpamplona.amethyst.commons.resources.nickname_dialog_explainer
import com.vitorpamplona.amethyst.commons.resources.nickname_dialog_title
import com.vitorpamplona.amethyst.commons.resources.nickname_label
import com.vitorpamplona.amethyst.commons.resources.nickname_save
import com.vitorpamplona.amethyst.commons.resources.nickname_summary_label
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
/**
* Edits the nickname (petname) and private note (summary) the account keeps for
@@ -55,79 +61,82 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
*
* Typing `:` offers the account's NIP-30 custom emojis; the mappings for any
* shortcode used are embedded (also encrypted) so the nickname renders with them.
*
* Shared by every front end: the caller supplies the account's [contactCards]
* and publishes the result in [onSave] (e.g. through its outbox relays). On
* Android, compose `WatchAndLoadMyEmojiList` alongside so the emoji packs load.
*/
@Composable
fun EditNicknameDialog(
user: User,
contactCards: ContactCardsState,
onSave: (petName: String?, summary: String?) -> Unit,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
) {
val nickname = rememberTextFieldState()
val summary = rememberTextFieldState()
val emojiSuggestions = remember(accountViewModel) { EmojiSuggestionState(accountViewModel.account.emoji) }
// which field the emoji autocomplete should insert into
val emojiSuggestions = remember(contactCards) { EmojiSuggestionState(contactCards.emojiPacks) }
// which field the emoji autocomplete should insert into: the last one edited
val emojiTarget = remember { mutableStateOf<TextFieldState?>(null) }
// keeps the account's selected emoji packs loaded while the dialog is open
WatchAndLoadMyEmojiList(accountViewModel)
// Prefill with the card's current encrypted values, if any. Decryption can
// be slow on external signers, so don't clobber anything already typed.
LaunchedEffect(user) {
accountViewModel.account.contactCards
contactCards
.petName(user.pubkeyHex)
?.takeIf { nickname.text.isEmpty() }
?.let { nickname.setTextAndPlaceCursorAtEnd(it) }
accountViewModel.account.contactCards
contactCards
.summary(user.pubkeyHex)
?.takeIf { summary.text.isEmpty() }
?.let { summary.setTextAndPlaceCursorAtEnd(it) }
}
fun watchEmojiIn(field: TextFieldState) {
emojiTarget.value = field
if (field.selection.collapsed) {
emojiSuggestions.processCurrentWord(field.currentWord())
// Feed the word under the cursor of the last-edited field to the autocomplete.
LaunchedEffect(nickname, summary) {
fun watch(field: TextFieldState) {
emojiTarget.value = field
if (field.selection.collapsed) {
emojiSuggestions.processCurrentWord(field.currentWord())
}
}
launch { snapshotFlow { nickname.text }.collect { watch(nickname) } }
launch { snapshotFlow { summary.text }.collect { watch(summary) } }
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(text = stringRes(R.string.nickname_dialog_title))
Text(text = stringResource(Res.string.nickname_dialog_title))
},
text = {
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = stringRes(R.string.nickname_dialog_explainer),
color = MaterialTheme.colorScheme.placeholderText,
text = stringResource(Res.string.nickname_dialog_explainer),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
ThinPaddingTextField(
OutlinedTextField(
state = nickname,
onTextChanged = { watchEmojiIn(nickname) },
singleLine = true,
lineLimits = TextFieldLineLimits.SingleLine,
label = {
Text(text = stringRes(R.string.nickname_label))
Text(text = stringResource(Res.string.nickname_label))
},
)
ThinPaddingTextField(
OutlinedTextField(
state = summary,
onTextChanged = { watchEmojiIn(summary) },
label = {
Text(text = stringRes(R.string.nickname_summary_label))
Text(text = stringResource(Res.string.nickname_summary_label))
},
)
ShowEmojiSuggestionList(
emojiSuggestions,
onSelect = {
emojiTarget.value?.replaceCurrentWord(":${it.code}:")
emojiSuggestions.reset()
onSelect = { emoji ->
emojiTarget.value?.let { emojiSuggestions.autocompleteInto(it, emoji) }
},
onFullSize = {
emojiTarget.value?.replaceCurrentWord(":${it.code}:")
emojiSuggestions.reset()
onFullSize = { emoji ->
emojiTarget.value?.let { emojiSuggestions.autocompleteInto(it, emoji) }
},
modifier = Modifier.heightIn(max = 200.dp),
)
@@ -136,30 +145,27 @@ fun EditNicknameDialog(
confirmButton = {
Button(
onClick = {
accountViewModel.updateContactCardPetName(
user = user,
petName =
nickname.text
.toString()
.trim()
.ifBlank { null },
summary =
summary.text
.toString()
.trim()
.ifBlank { null },
onSave(
nickname.text
.toString()
.trim()
.ifBlank { null },
summary.text
.toString()
.trim()
.ifBlank { null },
)
onDismiss()
},
) {
Text(stringRes(R.string.save))
Text(stringResource(Res.string.nickname_save))
}
},
dismissButton = {
Button(
onClick = onDismiss,
) {
Text(stringRes(R.string.cancel))
Text(stringResource(Res.string.nickname_cancel))
}
},
)
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers
package com.vitorpamplona.amethyst.commons.relayClient.assemblers
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -28,6 +28,11 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
val ContactCardKindList = listOf(ContactCardEvent.KIND)
/**
* Kind:30382 cards *about* [targets], written by [trustedAccounts] (the account
* itself plus its WoT trust providers). Fetches nicknames and scores for the
* users currently on screen.
*/
fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay(
targets: Set<HexKey>,
trustedAccounts: List<HexKey>,
@@ -46,3 +51,25 @@ fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay(
),
)
}
/**
* Every kind:30382 card *written by* [author] the account's own nicknames
* for the bulk download at login from the account's relays. Addressable events:
* one card per target user, hence the larger limit.
*/
fun filterContactCardsByAuthorInTheRelay(
relay: NormalizedRelayUrl,
author: HexKey,
since: Long?,
limit: Int = 500,
): RelayBasedFilter =
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = ContactCardKindList,
authors = listOf(author),
limit = limit,
since = since,
),
)