mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
Merge pull request #3548 from vitorpamplona/claude/user-nicknames-contactcard-mglbz8
Nickname users via NIP-85 contact cards (encrypted petname + private note, custom emojis)
This commit is contained in:
@@ -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))
|
||||
|
||||
+8
@@ -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 =
|
||||
|
||||
+24
-11
@@ -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<Nickname?> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -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<Filter>?,
|
||||
) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+4
-25
@@ -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<EmojiPackState.EmojiMedia>?,
|
||||
): List<EmojiUrlTag> {
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+6
@@ -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) }
|
||||
|
||||
+6
-3
@@ -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,
|
||||
)
|
||||
|
||||
+4
-20
@@ -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<EmojiPackState.EmojiMedia>?,
|
||||
): List<EmojiUrlTag> {
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+4
-19
@@ -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<EmojiPackState.EmojiMedia>?,
|
||||
): List<EmojiUrlTag> {
|
||||
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()
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+4
-18
@@ -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<EmojiMedia>?,
|
||||
): List<EmojiUrlTag> {
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+4
-20
@@ -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<EmojiPackState.EmojiMedia>?,
|
||||
): List<EmojiUrlTag> {
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+4
-20
@@ -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<EmojiMedia>?,
|
||||
): List<EmojiUrlTag> {
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+4
-19
@@ -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<EmojiPackState.EmojiMedia>?,
|
||||
): List<EmojiUrlTag> {
|
||||
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()
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+4
-20
@@ -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<HexKey>) {
|
||||
@@ -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<EmojiPackState.EmojiMedia>?,
|
||||
): List<EmojiUrlTag> {
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+125
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -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(
|
||||
|
||||
@@ -3545,6 +3545,10 @@
|
||||
<string name="playback_actions_dialog_title">Playback</string>
|
||||
<string name="video_quality_auto">Auto</string>
|
||||
|
||||
<!-- Nicknames (NIP-85 contact cards); the dialog strings live in commons -->
|
||||
<string name="edit_nickname">Edit nickname</string>
|
||||
<string name="nickname_private">Only visible to you</string>
|
||||
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<string name="cast_to_device">Cast to device</string>
|
||||
<string name="cast_stop_casting">Stop casting</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>
|
||||
|
||||
+18
@@ -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<EmojiUrlTag> {
|
||||
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.")
|
||||
|
||||
+23
-6
@@ -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<String> = MutableStateFlow("")
|
||||
val results: Flow<List<EmojiPackState.EmojiMedia>> =
|
||||
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()
|
||||
}
|
||||
}
|
||||
+65
@@ -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<ContactCardEvent>(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())
|
||||
}
|
||||
}
|
||||
+171
@@ -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<ContactCardEvent?> =
|
||||
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<Nickname?> =
|
||||
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<String> =
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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<String>,
|
||||
) {
|
||||
// content equality so flow distinctUntilChanged() dedupes re-decryptions of
|
||||
// the same card (ImmutableListOfLists itself compares by identity)
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is 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()
|
||||
}
|
||||
+13
-10
@@ -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),
|
||||
)
|
||||
}
|
||||
+172
@@ -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<TextFieldState?>(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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+31
-2
@@ -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<HexKey>,
|
||||
trustedAccounts: List<HexKey>,
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
+97
-44
@@ -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<EmojiUrlTag> = emptyList(),
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
publicInitializer: TagArrayBuilder<ContactCardEvent>.() -> Unit = {},
|
||||
privateInitializer: TagArrayBuilder<ContactCardEvent>.() -> 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<EmojiUrlTag> = emptyList(),
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
publicInitializer: TagArrayBuilder<ContactCardEvent>.() -> Unit = {},
|
||||
privateInitializer: TagArrayBuilder<ContactCardEvent>.() -> Unit = {},
|
||||
): EventTemplate<ContactCardEvent> {
|
||||
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<EmojiUrlTag> = emptyList(),
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): EventTemplate<ContactCardEvent> {
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-18
@@ -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<ContactCardEvent>.rank(rank: Int) = add(RankTag.assemble(rank))
|
||||
fun TagArrayBuilder<ContactCardEvent>.rank(rank: Int) = addUnique(RankTag.assemble(rank))
|
||||
|
||||
fun TagArrayBuilder<ContactCardEvent>.followers(count: Int) = add(FollowerCountTag.assemble(count))
|
||||
fun TagArrayBuilder<ContactCardEvent>.followers(count: Int) = addUnique(FollowerCountTag.assemble(count))
|
||||
|
||||
fun TagArrayBuilder<ContactCardEvent>.firstCreatedAt(timestamp: Long) = add(FirstCreatedAtTag.assemble(timestamp))
|
||||
fun TagArrayBuilder<ContactCardEvent>.firstCreatedAt(timestamp: Long) = addUnique(FirstCreatedAtTag.assemble(timestamp))
|
||||
|
||||
fun TagArrayBuilder<ContactCardEvent>.postCount(count: Int) = add(PostCountTag.assemble(count))
|
||||
fun TagArrayBuilder<ContactCardEvent>.postCount(count: Int) = addUnique(PostCountTag.assemble(count))
|
||||
|
||||
fun TagArrayBuilder<ContactCardEvent>.replyCount(count: Int) = add(ReplyCountTag.assemble(count))
|
||||
fun TagArrayBuilder<ContactCardEvent>.replyCount(count: Int) = addUnique(ReplyCountTag.assemble(count))
|
||||
|
||||
fun TagArrayBuilder<ContactCardEvent>.reactionsCount(count: Int) = add(ReactionsCountTag.assemble(count))
|
||||
fun TagArrayBuilder<ContactCardEvent>.reactionsCount(count: Int) = addUnique(ReactionsCountTag.assemble(count))
|
||||
|
||||
fun TagArrayBuilder<ContactCardEvent>.zapAmountReceived(sats: Long) = add(ZapAmountReceivedTag.assemble(sats))
|
||||
fun TagArrayBuilder<ContactCardEvent>.zapAmountReceived(sats: Long) = addUnique(ZapAmountReceivedTag.assemble(sats))
|
||||
|
||||
fun TagArrayBuilder<ContactCardEvent>.zapAmountSent(sats: Long) = add(ZapAmountSentTag.assemble(sats))
|
||||
fun TagArrayBuilder<ContactCardEvent>.zapAmountSent(sats: Long) = addUnique(ZapAmountSentTag.assemble(sats))
|
||||
|
||||
fun TagArrayBuilder<ContactCardEvent>.zapCountReceived(count: Int) = add(ZapCountReceivedTag.assemble(count))
|
||||
fun TagArrayBuilder<ContactCardEvent>.zapCountReceived(count: Int) = addUnique(ZapCountReceivedTag.assemble(count))
|
||||
|
||||
fun TagArrayBuilder<ContactCardEvent>.zapCountSent(count: Int) = add(ZapCountSentTag.assemble(count))
|
||||
fun TagArrayBuilder<ContactCardEvent>.zapCountSent(count: Int) = addUnique(ZapCountSentTag.assemble(count))
|
||||
|
||||
fun TagArrayBuilder<ContactCardEvent>.zapAvgAmountDayReceived(sats: Long) = add(ZapAvgAmountDayReceivedTag.assemble(sats))
|
||||
fun TagArrayBuilder<ContactCardEvent>.zapAvgAmountDayReceived(sats: Long) = addUnique(ZapAvgAmountDayReceivedTag.assemble(sats))
|
||||
|
||||
fun TagArrayBuilder<ContactCardEvent>.zapAvgAmountDaySent(sats: Long) = add(ZapAvgAmountDaySentTag.assemble(sats))
|
||||
fun TagArrayBuilder<ContactCardEvent>.zapAvgAmountDaySent(sats: Long) = addUnique(ZapAvgAmountDaySentTag.assemble(sats))
|
||||
|
||||
fun TagArrayBuilder<ContactCardEvent>.reportsCountReceived(count: Int) = add(ReportsCountReceivedTag.assemble(count))
|
||||
fun TagArrayBuilder<ContactCardEvent>.reportsCountReceived(count: Int) = addUnique(ReportsCountReceivedTag.assemble(count))
|
||||
|
||||
fun TagArrayBuilder<ContactCardEvent>.reportsCountSent(count: Int) = add(ReportsCountSentTag.assemble(count))
|
||||
fun TagArrayBuilder<ContactCardEvent>.reportsCountSent(count: Int) = addUnique(ReportsCountSentTag.assemble(count))
|
||||
|
||||
fun TagArrayBuilder<ContactCardEvent>.topic(topic: String) = add(TopicTag.assemble(topic))
|
||||
|
||||
fun TagArrayBuilder<ContactCardEvent>.activeHoursStart(hour: Int) = add(ActiveHoursStartTag.assemble(hour))
|
||||
fun TagArrayBuilder<ContactCardEvent>.activeHoursStart(hour: Int) = addUnique(ActiveHoursStartTag.assemble(hour))
|
||||
|
||||
fun TagArrayBuilder<ContactCardEvent>.activeHoursEnd(hour: Int) = add(ActiveHoursEndTag.assemble(hour))
|
||||
fun TagArrayBuilder<ContactCardEvent>.activeHoursEnd(hour: Int) = addUnique(ActiveHoursEndTag.assemble(hour))
|
||||
|
||||
fun TagArrayBuilder<ContactCardEvent>.petName(name: String) = add(PetNameTag.assemble(name))
|
||||
fun TagArrayBuilder<ContactCardEvent>.petName(name: String) = addUnique(PetNameTag.assemble(name))
|
||||
|
||||
fun TagArrayBuilder<ContactCardEvent>.summary(summary: String) = add(SummaryTag.assemble(summary))
|
||||
fun TagArrayBuilder<ContactCardEvent>.summary(summary: String) = addUnique(SummaryTag.assemble(summary))
|
||||
|
||||
+81
@@ -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)
|
||||
+185
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user