From accad0c77accc5783568b8a2437a0872755389ec Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Jun 2024 11:59:58 -0400 Subject: [PATCH] Adds a Share option with NIP-19 nprofile icon on the User Profile screen Moves to NProfile instead of NPub to cite users. Adds Npub or NIP-05 to the QR Screen --- .../amethyst/model/LocalCache.kt | 95 ++++++------ .../com/vitorpamplona/amethyst/model/User.kt | 145 +++++++++--------- .../amethyst/ui/actions/NewMessageTagger.kt | 20 +-- .../amethyst/ui/navigation/DrawerContent.kt | 9 +- .../ui/note/NIP05VerificationDisplay.kt | 2 +- .../amethyst/ui/note/NoteQuickActionMenu.kt | 20 ++- .../amethyst/ui/note/elements/DropDownMenu.kt | 10 +- .../amethyst/ui/qrcode/ShowQRDialog.kt | 47 ++++-- .../ui/screen/loggedIn/ProfileScreen.kt | 34 +++- .../quartz/encoders/Nip19Bech32.kt | 100 ++++++++---- 10 files changed, 281 insertions(+), 201 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index a88e97566..48c5f88d1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -241,33 +241,25 @@ object LocalCache { return users.get(key) } - fun getAddressableNoteIfExists(key: String): AddressableNote? { - return addressables.get(key) - } + fun getAddressableNoteIfExists(key: String): AddressableNote? = addressables.get(key) - fun getNoteIfExists(key: String): Note? { - return addressables.get(key) ?: notes.get(key) - } + fun getNoteIfExists(key: String): Note? = addressables.get(key) ?: notes.get(key) - fun getChannelIfExists(key: String): Channel? { - return channels.get(key) - } + fun getChannelIfExists(key: String): Channel? = channels.get(key) - fun getNoteIfExists(event: Event): Note? { - return if (event is AddressableEvent) { + fun getNoteIfExists(event: Event): Note? = + if (event is AddressableEvent) { getAddressableNoteIfExists(event.addressTag()) } else { getNoteIfExists(event.id) } - } - fun getOrCreateNote(event: Event): Note { - return if (event is AddressableEvent) { + fun getOrCreateNote(event: Event): Note = + if (event is AddressableEvent) { getOrCreateAddressableNote(event.address()) } else { getOrCreateNote(event.id) } - } fun checkGetOrCreateNote(key: String): Note? { checkNotInMainThread() @@ -348,8 +340,8 @@ object LocalCache { return HexValidator.isHex(key) } - fun checkGetOrCreateAddressableNote(key: String): AddressableNote? { - return try { + fun checkGetOrCreateAddressableNote(key: String): AddressableNote? = + try { val addr = ATag.parse(key, null) // relay doesn't matter for the index. if (addr != null) { getOrCreateAddressableNote(addr) @@ -360,7 +352,6 @@ object LocalCache { Log.e("LocalCache", "Invalid Key to create channel: $key", e) null } - } fun getOrCreateAddressableNoteInternal(key: ATag): AddressableNote { // checkNotInMainThread() @@ -395,6 +386,10 @@ object LocalCache { val newUserMetadata = event.contactMetaData() if (newUserMetadata != null) { oldUser.updateUserInfo(newUserMetadata, event) + if (relay != null) { + oldUser.addRelayBeingUsed(relay, event.createdAt) + oldUser.latestMetadataRelay = relay.url + } } // Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex()} ${oldUser.toBestDisplayName()} from ${relay?.url}") } else { @@ -428,11 +423,11 @@ object LocalCache { } } - fun formattedDateTime(timestamp: Long): String { - return Instant.ofEpochSecond(timestamp) + fun formattedDateTime(timestamp: Long): String = + Instant + .ofEpochSecond(timestamp) .atZone(ZoneId.systemDefault()) .format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a")) - } fun consume( event: TextNoteEvent, @@ -755,8 +750,8 @@ object LocalCache { } } - fun computeReplyTo(event: Event): List { - return when (event) { + fun computeReplyTo(event: Event): List = + when (event) { is PollNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } is WikiNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } is LongTextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } @@ -805,7 +800,6 @@ object LocalCache { else -> emptyList() } - } fun consume( event: PollNoteEvent, @@ -1218,7 +1212,8 @@ object LocalCache { if (deletionIndex.add(event)) { var deletedAtLeastOne = false - event.deleteEvents() + event + .deleteEvents() .mapNotNull { getNoteIfExists(it) } .forEach { deleteNote -> // must be the same author @@ -2030,16 +2025,16 @@ object LocalCache { suspend fun findStatusesForUser(user: User): ImmutableList { checkNotInMainThread() - return addressables.filter { _, it -> - val noteEvent = it.event - ( - noteEvent is StatusEvent && - noteEvent.pubKey == user.pubkeyHex && - !noteEvent.isExpired() && - noteEvent.content.isNotBlank() - ) - } - .sortedWith(compareBy({ it.event?.expiration() ?: it.event?.createdAt() }, { it.idHex })) + return addressables + .filter { _, it -> + val noteEvent = it.event + ( + noteEvent is StatusEvent && + noteEvent.pubKey == user.pubkeyHex && + !noteEvent.isExpired() && + noteEvent.content.isNotBlank() + ) + }.sortedWith(compareBy({ it.event?.expiration() ?: it.event?.createdAt() }, { it.idHex })) .reversed() .toImmutableList() } @@ -2066,9 +2061,7 @@ object LocalCache { val modificationCache = LruCache>(20) - fun cachedModificationEventsForNote(note: Note): List? { - return modificationCache[note.idHex] - } + fun cachedModificationEventsForNote(note: Note): List? = modificationCache[note.idHex] suspend fun findLatestModificationForNote(note: Note): List { checkNotInMainThread() @@ -2082,11 +2075,12 @@ object LocalCache { val time = TimeUtils.now() val newNotes = - notes.filter { _, item -> - val noteEvent = item.event + notes + .filter { _, item -> + val noteEvent = item.event - noteEvent is TextNoteModificationEvent && noteEvent.pubKey == originalAuthor && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time) - }.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + noteEvent is TextNoteModificationEvent && noteEvent.pubKey == originalAuthor && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time) + }.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) modificationCache.put(note.idHex, newNotes) @@ -2210,9 +2204,11 @@ object LocalCache { note.event is GenericRepostEvent ) && note.replyTo?.any { it.liveSet?.isInUse() == true } != true && - note.liveSet?.isInUse() != true && // don't delete if observing. + note.liveSet?.isInUse() != true && + // don't delete if observing. note.author?.pubkeyHex !in - accounts && // don't delete if it is the logged in account + accounts && + // don't delete if it is the logged in account note.event?.isTaggedUsers(accounts) != true // don't delete if it's a notification to the logged in user } @@ -2308,8 +2304,7 @@ object LocalCache { ?.hiddenUsers ?.map { userHex -> (notes.filter { _, it -> it.event?.pubKey() == userHex } + addressables.filter { _, it -> it.event?.pubKey() == userHex }).toSet() - } - ?.flatten() + }?.flatten() ?: emptyList() toBeRemoved.forEach { @@ -2596,8 +2591,8 @@ object LocalCache { } } - fun hasConsumed(notificationEvent: Event): Boolean { - return if (notificationEvent is AddressableEvent) { + fun hasConsumed(notificationEvent: Event): Boolean = + if (notificationEvent is AddressableEvent) { val note = addressables.get(notificationEvent.addressTag()) val noteEvent = note?.event noteEvent != null && notificationEvent.createdAt <= noteEvent.createdAt() @@ -2605,7 +2600,6 @@ object LocalCache { val note = notes.get(notificationEvent.id) note?.event != null } - } } @Stable @@ -2617,8 +2611,7 @@ class LocalCacheLiveData { private val bundler = BundledInsert(1000, Dispatchers.IO) fun invalidateData(newNote: Note) { - bundler.invalidateList(newNote) { - bundledNewNotes -> + bundler.invalidateList(newNote) { bundledNewNotes -> _newEventBundles.emit(bundledNewNotes) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 9844ef622..0d78e8c3e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -34,7 +34,9 @@ import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.Lud06 +import com.vitorpamplona.quartz.encoders.Nip19Bech32 import com.vitorpamplona.quartz.encoders.toNpub +import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent import com.vitorpamplona.quartz.events.BookmarkListEvent import com.vitorpamplona.quartz.events.ChatroomKey import com.vitorpamplona.quartz.events.ContactListEvent @@ -49,10 +51,13 @@ import kotlinx.coroutines.flow.MutableStateFlow import java.math.BigDecimal @Stable -class User(val pubkeyHex: String) { +class User( + val pubkeyHex: String, +) { var info: UserMetadata? = null var latestMetadata: MetadataEvent? = null + var latestMetadataRelay: String? = null var latestContactList: ContactListEvent? = null var latestBookmarkList: BookmarkListEvent? = null @@ -76,7 +81,16 @@ class User(val pubkeyHex: String) { fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex() - fun toNostrUri() = "nostr:${pubkeyNpub()}" + fun toNProfile(): String { + val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(pubkeyHex))?.event as? AdvertisedRelayListEvent)?.writeRelays() + + return Nip19Bech32.createNProfile( + pubkeyHex, + relayList?.take(3) ?: listOfNotNull(latestMetadataRelay), + ) + } + + fun toNostrUri() = "nostr:${toNProfile()}" override fun toString(): String = pubkeyHex @@ -96,17 +110,11 @@ class User(val pubkeyHex: String) { return firstName } - fun toBestDisplayName(): String { - return info?.bestName() ?: pubkeyDisplayHex() - } + fun toBestDisplayName(): String = info?.bestName() ?: pubkeyDisplayHex() - fun nip05(): String? { - return info?.nip05 - } + fun nip05(): String? = info?.nip05 - fun profilePicture(): String? { - return info?.picture - } + fun profilePicture(): String? = info?.picture fun updateBookmark(event: BookmarkListEvent) { if (event.id == latestBookmarkList?.id) return @@ -132,10 +140,18 @@ class User(val pubkeyHex: String) { // Update Followers of the past user list // Update Followers of the new contact list (oldContactListEvent)?.unverifiedFollowKeySet()?.forEach { - LocalCache.getUserIfExists(it)?.liveSet?.innerFollowers?.invalidateData() + LocalCache + .getUserIfExists(it) + ?.liveSet + ?.innerFollowers + ?.invalidateData() } (latestContactList)?.unverifiedFollowKeySet()?.forEach { - LocalCache.getUserIfExists(it)?.liveSet?.innerFollowers?.invalidateData() + LocalCache + .getUserIfExists(it) + ?.liveSet + ?.innerFollowers + ?.invalidateData() } liveSet?.innerRelays?.invalidateData() @@ -198,25 +214,19 @@ class User(val pubkeyHex: String) { return amount } - fun reportsBy(user: User): Set { - return reports[user] ?: emptySet() - } + fun reportsBy(user: User): Set = reports[user] ?: emptySet() - fun countReportAuthorsBy(users: Set): Int { - return reports.count { it.key.pubkeyHex in users } - } + fun countReportAuthorsBy(users: Set): Int = reports.count { it.key.pubkeyHex in users } - fun reportsBy(users: Set): List { - return reports + fun reportsBy(users: Set): List = + reports .mapNotNull { if (it.key.pubkeyHex in users) { it.value } else { null } - } - .flatten() - } + }.flatten() @Synchronized private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom { @@ -235,9 +245,7 @@ class User(val pubkeyHex: String) { return getOrCreatePrivateChatroom(key) } - private fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom { - return privateChatrooms[key] ?: getOrCreatePrivateChatroomSync(key) - } + private fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom = privateChatrooms[key] ?: getOrCreatePrivateChatroomSync(key) fun addMessage( room: ChatroomKey, @@ -326,13 +334,9 @@ class User(val pubkeyHex: String) { liveSet?.innerMetadata?.invalidateData() } - fun isFollowing(user: User): Boolean { - return latestContactList?.isTaggedUser(user.pubkeyHex) ?: false - } + fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false - fun isFollowingHashtag(tag: String): Boolean { - return latestContactList?.isTaggedHash(tag) ?: false - } + fun isFollowingHashtag(tag: String): Boolean = latestContactList?.isTaggedHash(tag) ?: false fun isFollowingHashtagCached(tag: String): Boolean { return latestContactList?.verifiedFollowTagSet?.let { @@ -362,37 +366,21 @@ class User(val pubkeyHex: String) { ?: false } - fun transientFollowCount(): Int? { - return latestContactList?.unverifiedFollowKeySet()?.size - } + fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size - suspend fun transientFollowerCount(): Int { - return LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false } - } + suspend fun transientFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false } - fun cachedFollowingKeySet(): Set { - return latestContactList?.verifiedFollowKeySet ?: emptySet() - } + fun cachedFollowingKeySet(): Set = latestContactList?.verifiedFollowKeySet ?: emptySet() - fun cachedFollowingTagSet(): Set { - return latestContactList?.verifiedFollowTagSet ?: emptySet() - } + fun cachedFollowingTagSet(): Set = latestContactList?.verifiedFollowTagSet ?: emptySet() - fun cachedFollowingGeohashSet(): Set { - return latestContactList?.verifiedFollowGeohashSet ?: emptySet() - } + fun cachedFollowingGeohashSet(): Set = latestContactList?.verifiedFollowGeohashSet ?: emptySet() - fun cachedFollowingCommunitiesSet(): Set { - return latestContactList?.verifiedFollowCommunitySet ?: emptySet() - } + fun cachedFollowingCommunitiesSet(): Set = latestContactList?.verifiedFollowCommunitySet ?: emptySet() - fun cachedFollowCount(): Int? { - return latestContactList?.verifiedFollowKeySet?.size - } + fun cachedFollowCount(): Int? = latestContactList?.verifiedFollowKeySet?.size - suspend fun cachedFollowerCount(): Int { - return LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false } - } + suspend fun cachedFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false } fun hasSentMessagesTo(key: ChatroomKey?): Boolean { val messagesToUser = privateChatrooms[key] ?: return false @@ -403,16 +391,13 @@ class User(val pubkeyHex: String) { fun hasReport( loggedIn: User, type: ReportEvent.ReportType, - ): Boolean { - return reports[loggedIn]?.firstOrNull { + ): Boolean = + reports[loggedIn]?.firstOrNull { it.event is ReportEvent && (it.event as ReportEvent).reportedAuthor().any { it.reportType == type } } != null - } - fun anyNameStartsWith(username: String): Boolean { - return info?.anyNameStartsWith(username) ?: false - } + fun anyNameStartsWith(username: String): Boolean = info?.anyNameStartsWith(username) ?: false var liveSet: UserLiveSet? = null var flowSet: UserFlowSet? = null @@ -473,14 +458,14 @@ class User(val pubkeyHex: String) { } @Stable -class UserFlowSet(u: User) { +class UserFlowSet( + u: User, +) { // Observers line up here. val follows = UserBundledRefresherFlow(u) val relays = UserBundledRefresherFlow(u) - fun isInUse(): Boolean { - return relays.stateFlow.subscriptionCount.value > 0 || follows.stateFlow.subscriptionCount.value > 0 - } + fun isInUse(): Boolean = relays.stateFlow.subscriptionCount.value > 0 || follows.stateFlow.subscriptionCount.value > 0 fun destroy() { relays.destroy() @@ -489,7 +474,9 @@ class UserFlowSet(u: User) { } @Stable -class UserLiveSet(u: User) { +class UserLiveSet( + u: User, +) { val innerMetadata = UserBundledRefresherLiveData(u) // UI Observers line up here. @@ -521,8 +508,8 @@ class UserLiveSet(u: User) { val userMetadataInfo = innerMetadata.map { it.user.info }.distinctUntilChanged() - fun isInUse(): Boolean { - return metadata.hasObservers() || + fun isInUse(): Boolean = + metadata.hasObservers() || follows.hasObservers() || followers.hasObservers() || reports.hasObservers() || @@ -535,7 +522,6 @@ class UserLiveSet(u: User) { profilePictureChanges.hasObservers() || nip05Changes.hasObservers() || userMetadataInfo.hasObservers() - } fun destroy() { innerMetadata.destroy() @@ -558,7 +544,9 @@ data class RelayInfo( var counter: Long, ) -class UserBundledRefresherLiveData(val user: User) : LiveData(UserState(user)) { +class UserBundledRefresherLiveData( + val user: User, +) : LiveData(UserState(user)) { // Refreshes observers in batches. private val bundler = BundledUpdate(500, Dispatchers.IO) @@ -585,7 +573,9 @@ class UserBundledRefresherLiveData(val user: User) : LiveData(UserSta } @Stable -class UserBundledRefresherFlow(val user: User) { +class UserBundledRefresherFlow( + val user: User, +) { // Refreshes observers in batches. private val bundler = BundledUpdate(500, Dispatchers.IO) val stateFlow = MutableStateFlow(UserState(user)) @@ -605,7 +595,10 @@ class UserBundledRefresherFlow(val user: User) { } } -class UserLoadingLiveData(val user: User, initialValue: Y?) : MediatorLiveData(initialValue) { +class UserLoadingLiveData( + val user: User, + initialValue: Y?, +) : MediatorLiveData(initialValue) { override fun onActive() { super.onActive() NostrSingleUserDataSource.add(user) @@ -617,4 +610,6 @@ class UserLoadingLiveData(val user: User, initialValue: Y?) : MediatorLiveDat } } -@Immutable class UserState(val user: User) +@Immutable class UserState( + val user: User, +) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt index 8cbeb910a..710563b55 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt @@ -100,10 +100,10 @@ class NewMessageTagger( val results = parseDirtyWordForKey(word) when (val entity = results?.key?.entity) { is Nip19Bech32.NPub -> { - getNostrAddress(dao.getOrCreateUser(entity.hex).pubkeyNpub(), results.restOfWord) + getNostrAddress(dao.getOrCreateUser(entity.hex).toNProfile(), results.restOfWord) } is Nip19Bech32.NProfile -> { - getNostrAddress(dao.getOrCreateUser(entity.hex).pubkeyNpub(), results.restOfWord) + getNostrAddress(dao.getOrCreateUser(entity.hex).toNProfile(), results.restOfWord) } is Nip19Bech32.Note -> { @@ -138,17 +138,15 @@ class NewMessageTagger( word } } - } - .joinToString(" ") - } - .joinToString("\n") + }.joinToString(" ") + }.joinToString("\n") } fun getNostrAddress( bechAddress: String, restOfTheWord: String?, - ): String { - return if (restOfTheWord.isNullOrEmpty()) { + ): String = + if (restOfTheWord.isNullOrEmpty()) { "nostr:$bechAddress" } else { if (Bech32.ALPHABET.contains(restOfTheWord.get(0), true)) { @@ -157,9 +155,11 @@ class NewMessageTagger( "nostr:${bechAddress}$restOfTheWord" } } - } - @Immutable data class DirtyKeyInfo(val key: Nip19Bech32.ParseReturn, val restOfWord: String?) + @Immutable data class DirtyKeyInfo( + val key: Nip19Bech32.ParseReturn, + val restOfWord: String?, + ) fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? { var key = mightBeAKey diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index 4202294fc..edcf50c7f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -168,8 +168,7 @@ fun DrawerContent( BottomContent( accountViewModel.account.userProfile(), drawerState, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, - loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, + accountViewModel, nav, ) } @@ -741,8 +740,7 @@ fun IconRowRelays( fun BottomContent( user: User, drawerState: DrawerState, - loadProfilePicture: Boolean, - loadRobohash: Boolean, + accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { val coroutineScope = rememberCoroutineScope() @@ -800,8 +798,7 @@ fun BottomContent( if (dialogOpen) { ShowQRDialog( user, - loadProfilePicture = loadProfilePicture, - loadRobohash = loadRobohash, + accountViewModel, onScan = { dialogOpen = false coroutineScope.launch { drawerState.close() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index 5435d2f7d..3e6e49e0e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -310,7 +310,7 @@ fun DisplayStatus( } @Composable -private fun DisplayNIP05( +fun DisplayNIP05( nip05: String, nip05Verified: MutableState, accountViewModel: AccountViewModel, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 159961c92..2cc7a5c62 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -84,6 +84,7 @@ import androidx.core.graphics.ColorUtils import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.actions.NewPostView import com.vitorpamplona.amethyst.ui.components.SelectTextDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -95,7 +96,6 @@ import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground import com.vitorpamplona.quartz.events.AudioTrackEvent import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.PeopleListEvent -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch private fun lightenColor( @@ -110,6 +110,10 @@ private fun lightenColor( return Color(argb) } +val externalLinkForUser = { user: User -> + "https://njump.me/${user.toNProfile()}" +} + val externalLinkForNote = { note: Note -> if (note is AddressableNote) { if (note.event?.getReward() != null) { @@ -298,10 +302,12 @@ private fun RenderMainPopup( Icons.Default.AlternateEmail, stringRes(R.string.quick_action_copy_user_id), ) { - scope.launch(Dispatchers.IO) { - clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")) - showToast(R.string.copied_user_id_to_clipboard) - onDismiss() + note.author?.let { + scope.launch { + clipboardManager.setText(AnnotatedString(it.toNostrUri())) + showToast(R.string.copied_user_id_to_clipboard) + onDismiss() + } } } VerticalDivider(color = primaryLight) @@ -309,8 +315,8 @@ private fun RenderMainPopup( Icons.Default.FormatQuote, stringRes(R.string.quick_action_copy_note_id), ) { - scope.launch(Dispatchers.IO) { - clipboardManager.setText(AnnotatedString("nostr:${note.toNEvent()}")) + scope.launch { + clipboardManager.setText(AnnotatedString(note.toNostrUri())) showToast(R.string.copied_note_id_to_clipboard) onDismiss() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index 1c251469d..ffa6e8a74 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -197,9 +197,11 @@ fun NoteDropDownMenu( DropdownMenuItem( text = { Text(stringRes(R.string.copy_user_pubkey)) }, onClick = { - scope.launch(Dispatchers.IO) { - clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")) - onDismiss() + note.author?.let { + scope.launch(Dispatchers.IO) { + clipboardManager.setText(AnnotatedString("nostr:${it.pubkeyNpub()}")) + onDismiss() + } } }, ) @@ -207,7 +209,7 @@ fun NoteDropDownMenu( text = { Text(stringRes(R.string.copy_note_id)) }, onClick = { scope.launch(Dispatchers.IO) { - clipboardManager.setText(AnnotatedString("nostr:" + note.toNEvent())) + clipboardManager.setText(AnnotatedString(note.toNostrUri())) onDismiss() } }, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt index 981efabe2..6509f4253 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt @@ -45,16 +45,22 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji +import com.vitorpamplona.amethyst.ui.components.DisplayNIP05 import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.components.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.components.nip05VerificationAsAState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.quartz.events.UserMetadata @@ -62,21 +68,20 @@ import com.vitorpamplona.quartz.events.UserMetadata @Preview @Composable fun ShowQRDialogPreview() { - val user = User("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") - - user.info = + val accountViewModel = mockAccountViewModel() + accountViewModel.userProfile().info = UserMetadata().apply { name = "My Name" picture = "Picture" + nip05 = null banner = "http://banner.com/test" website = "http://mywebsite.com/test" about = "This is the about me" } ShowQRDialog( - user = user, - loadProfilePicture = false, - loadRobohash = false, + user = accountViewModel.userProfile(), + accountViewModel = accountViewModel, onScan = {}, onClose = {}, ) @@ -85,8 +90,7 @@ fun ShowQRDialogPreview() { @Composable fun ShowQRDialog( user: User, - loadProfilePicture: Boolean, - loadRobohash: Boolean, + accountViewModel: AccountViewModel, onScan: (String) -> Unit, onClose: () -> Unit, ) { @@ -126,8 +130,8 @@ fun ShowQRDialog( .height(100.dp) .clip(shape = CircleShape) .border(3.dp, MaterialTheme.colorScheme.background, CircleShape), - loadProfilePicture = loadProfilePicture, - loadRobohash = loadRobohash, + loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, ) } Row( @@ -141,13 +145,34 @@ fun ShowQRDialog( fontSize = 18.sp, ) } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) { + val nip05 = user.nip05() + if (nip05 != null) { + val nip05Verified = + nip05VerificationAsAState(user.info!!, user.pubkeyHex, accountViewModel) + + DisplayNIP05(nip05, nip05Verified, accountViewModel) + } else { + Text( + text = user.pubkeyDisplayHex(), + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } } Row( horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth().padding(horizontal = Size35dp), ) { - QrCodeDrawer("nostr:${user.pubkeyNpub()}") + QrCodeDrawer(user.toNostrUri()) } Row(modifier = Modifier.padding(horizontal = 30.dp)) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 776111758..a7d3e1aca 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import android.content.Intent import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image @@ -103,6 +104,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -136,6 +138,7 @@ import com.vitorpamplona.amethyst.ui.note.DrawPlayName import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog import com.vitorpamplona.amethyst.ui.note.LightningAddressIcon import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.note.externalLinkForUser import com.vitorpamplona.amethyst.ui.note.payViaIntent import com.vitorpamplona.amethyst.ui.qrcode.ShowQRDialog import com.vitorpamplona.amethyst.ui.screen.FeedState @@ -996,9 +999,8 @@ private fun DrawAdditionalInfo( if (dialogOpen) { ShowQRDialog( - user, - accountViewModel.settings.showProfilePictures.value, - loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, + user = user, + accountViewModel = accountViewModel, onScan = { dialogOpen = false nav(it) @@ -1870,6 +1872,32 @@ fun UserProfileDropDownMenu( }, ) + val actContext = LocalContext.current + + DropdownMenuItem( + text = { Text(stringRes(R.string.quick_action_share)) }, + onClick = { + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + externalLinkForUser(user), + ) + putExtra( + Intent.EXTRA_TITLE, + stringRes(actContext, R.string.quick_action_share_browser_link), + ) + } + + val shareIntent = + Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share)) + ContextCompat.startActivity(actContext, shareIntent, null) + onDismiss() + }, + ) + if (accountViewModel.userProfile() != user) { HorizontalDivider(thickness = DividerThickness) if (accountViewModel.account.isHidden(user)) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt index 3a8cab829..951bfaa83 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt @@ -39,7 +39,9 @@ object Nip19Bech32 { ADDRESS, } - enum class TlvTypes(val id: Byte) { + enum class TlvTypes( + val id: Byte, + ) { SPECIAL(0), RELAY(1), AUTHOR(2), @@ -53,33 +55,59 @@ object Nip19Bech32 { ) @Immutable - data class ParseReturn(val entity: Entity, val additionalChars: String? = null) + data class ParseReturn( + val entity: Entity, + val additionalChars: String? = null, + ) interface Entity @Immutable - data class NSec(val hex: String) : Entity + data class NSec( + val hex: String, + ) : Entity @Immutable - data class NPub(val hex: String) : Entity + data class NPub( + val hex: String, + ) : Entity @Immutable - data class Note(val hex: String) : Entity + data class Note( + val hex: String, + ) : Entity @Immutable - data class NProfile(val hex: String, val relay: List) : Entity + data class NProfile( + val hex: String, + val relay: List, + ) : Entity @Immutable - data class NEvent(val hex: String, val relay: List, val author: String?, val kind: Int?) : Entity + data class NEvent( + val hex: String, + val relay: List, + val author: String?, + val kind: Int?, + ) : Entity @Immutable - data class NAddress(val atag: String, val relay: List, val author: String, val kind: Int) : Entity + data class NAddress( + val atag: String, + val relay: List, + val author: String, + val kind: Int, + ) : Entity @Immutable - data class NRelay(val relay: List) : Entity + data class NRelay( + val relay: List, + ) : Entity @Immutable - data class NEmbed(val event: Event) : Entity + data class NEmbed( + val event: Event, + ) : Entity fun uriToRoute(uri: String?): ParseReturn? { if (uri == null) return null @@ -108,8 +136,8 @@ object Nip19Bech32 { type: String, key: String?, additionalChars: String?, - ): ParseReturn? { - return try { + ): ParseReturn? = + try { val bytes = (type + key).bechToBytes() when (type.lowercase()) { @@ -129,7 +157,6 @@ object Nip19Bech32 { Log.w("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e) null } - } private fun nembed(bytes: ByteArray): NEmbed? { if (bytes.isEmpty()) return null @@ -205,21 +232,30 @@ object Nip19Bech32 { author: String?, kind: Int?, relay: String?, - ): String { - return TlvBuilder() + ): String = + TlvBuilder() .apply { addHex(TlvTypes.SPECIAL, idHex) addStringIfNotNull(TlvTypes.RELAY, relay) addHexIfNotNull(TlvTypes.AUTHOR, author) addIntIfNotNull(TlvTypes.KIND, kind) - } - .build() + }.build() .toNEvent() - } - fun createNEmbed(event: Event): String { - return gzip(event.toJson()).toNEmbed() - } + fun createNProfile( + authorPubKeyHex: String, + relay: List, + ): String = + TlvBuilder() + .apply { + addHex(TlvTypes.SPECIAL, authorPubKeyHex) + relay.forEach { + addStringIfNotNull(TlvTypes.RELAY, it) + } + }.build() + .toNProfile() + + fun createNEmbed(event: Event): String = gzip(event.toJson()).toNEmbed() fun gzip(content: String): ByteArray { val bos = ByteArrayOutputStream() @@ -239,23 +275,24 @@ fun ByteArray.toNote() = Bech32.encodeBytes(hrp = "note", this, Bech32.Encoding. fun ByteArray.toNEvent() = Bech32.encodeBytes(hrp = "nevent", this, Bech32.Encoding.Bech32) +fun ByteArray.toNProfile() = Bech32.encodeBytes(hrp = "nprofile", this, Bech32.Encoding.Bech32) + fun ByteArray.toNAddress() = Bech32.encodeBytes(hrp = "naddr", this, Bech32.Encoding.Bech32) fun ByteArray.toLnUrl() = Bech32.encodeBytes(hrp = "lnurl", this, Bech32.Encoding.Bech32) fun ByteArray.toNEmbed() = Bech32.encodeBytes(hrp = "nembed", this, Bech32.Encoding.Bech32) -fun decodePublicKey(key: String): ByteArray { - return when (val parsed = Nip19Bech32.uriToRoute(key)?.entity) { +fun decodePublicKey(key: String): ByteArray = + when (val parsed = Nip19Bech32.uriToRoute(key)?.entity) { is Nip19Bech32.NSec -> KeyPair(privKey = key.bechToBytes()).pubKey is Nip19Bech32.NPub -> parsed.hex.hexToByteArray() is Nip19Bech32.NProfile -> parsed.hex.hexToByteArray() else -> Hex.decode(key) // crashes on purpose } -} -fun decodePrivateKeyAsHexOrNull(key: String): HexKey? { - return try { +fun decodePrivateKeyAsHexOrNull(key: String): HexKey? = + try { when (val parsed = Nip19Bech32.uriToRoute(key)?.entity) { is Nip19Bech32.NSec -> parsed.hex is Nip19Bech32.NPub -> null @@ -271,10 +308,9 @@ fun decodePrivateKeyAsHexOrNull(key: String): HexKey? { if (e is CancellationException) throw e null } -} -fun decodePublicKeyAsHexOrNull(key: String): HexKey? { - return try { +fun decodePublicKeyAsHexOrNull(key: String): HexKey? = + try { when (val parsed = Nip19Bech32.uriToRoute(key)?.entity) { is Nip19Bech32.NSec -> KeyPair(privKey = key.bechToBytes()).pubKey.toHexKey() is Nip19Bech32.NPub -> parsed.hex @@ -290,10 +326,9 @@ fun decodePublicKeyAsHexOrNull(key: String): HexKey? { if (e is CancellationException) throw e null } -} -fun decodeEventIdAsHexOrNull(key: String): HexKey? { - return try { +fun decodeEventIdAsHexOrNull(key: String): HexKey? = + try { when (val parsed = Nip19Bech32.uriToRoute(key)?.entity) { is Nip19Bech32.NSec -> null is Nip19Bech32.NPub -> null @@ -309,7 +344,6 @@ fun decodeEventIdAsHexOrNull(key: String): HexKey? { if (e is CancellationException) throw e null } -} fun TlvBuilder.addString( type: Nip19Bech32.TlvTypes,