diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 7747c59eb..0cd471da5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -176,7 +176,6 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo @@ -352,6 +351,7 @@ class Account( val liveHomeFollowLists: StateFlow = FeedTopNavFilterState( feedFilterListName = settings.defaultHomeFollowList, + kind3Follows = kind3FollowList.flow, allFollows = allFollows.flow, locationFlow = geolocationFlow, followsRelays = defaultGlobalRelays.flow, @@ -367,6 +367,7 @@ class Account( val liveStoriesFollowLists: StateFlow = FeedTopNavFilterState( feedFilterListName = settings.defaultStoriesFollowList, + kind3Follows = kind3FollowList.flow, allFollows = allFollows.flow, locationFlow = geolocationFlow, followsRelays = defaultGlobalRelays.flow, @@ -382,6 +383,7 @@ class Account( val liveDiscoveryFollowLists: StateFlow = FeedTopNavFilterState( feedFilterListName = settings.defaultDiscoveryFollowList, + kind3Follows = kind3FollowList.flow, allFollows = allFollows.flow, locationFlow = geolocationFlow, followsRelays = defaultGlobalRelays.flow, @@ -397,6 +399,7 @@ class Account( val liveNotificationFollowLists: StateFlow = FeedTopNavFilterState( feedFilterListName = settings.defaultNotificationFollowList, + kind3Follows = kind3FollowList.flow, allFollows = allFollows.flow, locationFlow = geolocationFlow, followsRelays = defaultGlobalRelays.flow, @@ -1468,18 +1471,6 @@ class Account( val mine = signedEvents.wraps.filter { (it.recipientPubKey() == signer.pubKey) } mine.forEach { giftWrap -> - val gift = giftWrap.unwrapOrNull(signer) - if (gift is SealedRumorEvent) { - val rumor = gift.unsealOrNull(signer) - if (rumor != null) { - cache.justConsumeMyOwnEvent(rumor) - } - } - - if (gift != null) { - cache.justConsumeMyOwnEvent(gift) - } - cache.justConsumeMyOwnEvent(giftWrap) } @@ -1693,6 +1684,10 @@ class Account( fun isFollowing(user: HexKey): Boolean = user in followingKeySet() + fun isKnown(user: User): Boolean = user.pubkeyHex in allFollows.flow.value.authors + + fun isKnown(user: HexKey): Boolean = user in allFollows.flow.value.authors + fun isAcceptable(note: Note): Boolean { return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index e81151655..c1bcc246e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -101,6 +101,9 @@ val ALL_FOLLOWS = " All Follows " // This has spaces to avoid mixing with a potential NIP-51 list with the same name. val ALL_USER_FOLLOWS = " All User Follows " +// This has spaces to avoid mixing with a potential NIP-51 list with the same name. +val KIND3_FOLLOWS = " Main User Follows " + // This has spaces to avoid mixing with a potential NIP-51 list with the same name. val AROUND_ME = " Around Me " diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt index 26411410e..f9afce5bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt @@ -64,7 +64,7 @@ fun RenderHashTagIconsPreview() { ) { paragraph, state, spaceWidth, modifier -> RenderTextParagraph(paragraph, spaceWidth, modifier) { word -> when (word) { - is HashTagSegment -> HashTag(word, EmptyNav) + is HashTagSegment -> HashTag(word, EmptyNav()) is RegularTextSegment -> Text(word.segmentText) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt index 788272c28..983e623a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.model.nip51Lists.peopleList +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -30,8 +32,20 @@ import com.vitorpamplona.amethyst.model.filter import com.vitorpamplona.amethyst.model.updateFlow import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.update +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.description +import com.vitorpamplona.quartz.nip51Lists.followList.image +import com.vitorpamplona.quartz.nip51Lists.followList.person +import com.vitorpamplona.quartz.nip51Lists.followList.personFirst +import com.vitorpamplona.quartz.nip51Lists.followList.removePerson +import com.vitorpamplona.quartz.nip51Lists.followList.title +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.peopleList.description +import com.vitorpamplona.quartz.nip51Lists.peopleList.image +import com.vitorpamplona.quartz.nip51Lists.peopleList.name import com.vitorpamplona.quartz.utils.flattenToSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -46,6 +60,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.update +import java.util.UUID /** * Maintains several stateflows for each step in processing PeopleLists @@ -59,7 +74,7 @@ class FollowListsState( ) { val user = cache.getOrCreateUser(signer.pubKey) - fun existingPeopleListNotes() = cache.addressables.filter(FollowListEvent.Companion.KIND, user.pubkeyHex) + fun existingPeopleListNotes() = cache.addressables.filter(FollowListEvent.KIND, user.pubkeyHex) val followListVersions = MutableStateFlow(0) @@ -83,7 +98,7 @@ class FollowListsState( .transformLatest { emitAll(it.updateFlow()) } .onStart { emit(followListNotes.value.events()) } .flowOn(Dispatchers.IO) - .stateIn(scope, SharingStarted.Companion.Eagerly, emptyList()) + .stateIn(scope, SharingStarted.Eagerly, emptyList()) fun List.mapToUserIdSet() = this.map { it.followIdSet() }.flattenToSet() @@ -99,6 +114,7 @@ class FollowListsState( identifierTag = this.dTag(), title = this.title() ?: this.dTag(), description = this.description(), + image = this.image(), privateMembers = emptySet(), publicMembers = cache.load(this.followIdSet()), ) @@ -112,6 +128,18 @@ class FollowListsState( .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Companion.Eagerly, emptyList()) + fun List.select(dTag: String) = + this.firstOrNull { + it.identifierTag == dTag + } + + fun selectList(dTag: String) = uiListFlow.value.select(dTag) + + fun selectListFlow(dTag: String) = + uiListFlow + .map { it.select(dTag) } + .onStart { emit(selectList(dTag)) } + fun isUserInFollowSets(user: User): Boolean = allPeopleListProfiles.value.contains(user.pubkeyHex) fun DeletionEvent.hasDeletedAnyFollowList() = deleteAddressesWithKind(FollowListEvent.Companion.KIND) || deletesAnyEventIn(followListsEventIds.value) @@ -140,4 +168,146 @@ class FollowListsState( fun forceRefresh() { followListVersions.update { it + 1 } } + + // -------------- + // Updating Lists + // -------------- + + fun getPeopleListNote(noteIdentifier: String): AddressableNote? = existingPeopleListNotes().find { it.dTag() == noteIdentifier } + + fun getPeopleList(noteIdentifier: String): FollowListEvent = getPeopleListNote(noteIdentifier)?.event as FollowListEvent + + fun User.toUserTag() = UserTag(this.pubkeyHex, this.bestRelayHint()) + + fun Set.toUserTags() = map { it.toUserTag() } + + suspend fun addFollowList( + name: String, + desc: String?, + image: String?, + member: User? = null, + isPrivate: Boolean = false, + account: Account, + ): String { + val dTag = UUID.randomUUID().toString() + + val newListTemplate = + FollowListEvent.build( + name = name, + people = if (!isPrivate && member != null) listOf(member.toUserTag()) else emptyList(), + dTag = dTag, + ) { + if (desc != null) description(desc) + if (image != null) image(image) + } + + val newList = signer.sign(newListTemplate) + + account.sendMyPublicAndPrivateOutbox(newList) + return dTag + } + + suspend fun updateMetadata( + name: String?, + desc: String?, + image: String?, + peopleList: PeopleList, + account: Account, + ) { + val listEvent = getPeopleList(peopleList.identifierTag) + + val template = + listEvent.update { + if (name != null) title(name) + if (desc != null) description(desc) + if (image != null) image(image) + } + + val newList = signer.sign(template) + + account.sendMyPublicAndPrivateOutbox(newList) + } + + suspend fun cloneFollowSet( + currentFollowPack: PeopleList, + customCloneName: String?, + customCloneDescription: String?, + account: Account, + ) { + val listEvent = getPeopleList(currentFollowPack.identifierTag) + + val template = + listEvent.update { + // new list + dTag(UUID.randomUUID().toString()) + + // updates names + if (customCloneName != null) title(customCloneName) + if (customCloneDescription != null) description(customCloneDescription) + } + + val newList = signer.sign(template) + + account.sendMyPublicAndPrivateOutbox(newList) + } + + suspend fun deleteFollowSet( + identifierTag: String, + account: Account, + ) { + val followListEvent = getPeopleList(identifierTag) + val deletionEvent = account.signer.sign(DeletionEvent.build(listOf(followListEvent))) + account.sendMyPublicAndPrivateOutbox(deletionEvent) + } + + suspend fun addUserToSet( + user: User, + identifierTag: String, + account: Account, + ) { + val followListEvent = getPeopleList(identifierTag) + + val template = + followListEvent.update { + person(user.pubkeyHex, user.bestRelayHint()) + } + + val newList = signer.sign(template) + + account.sendMyPublicAndPrivateOutbox(newList) + } + + suspend fun addUserFirstToSet( + user: User, + identifierTag: String, + account: Account, + ) { + val followListEvent = getPeopleList(identifierTag) + + val template = + followListEvent.update { + personFirst(user.pubkeyHex, user.bestRelayHint()) + } + + val newList = signer.sign(template) + + account.sendMyPublicAndPrivateOutbox(newList) + } + + suspend fun removeUserFromSet( + user: User, + identifierTag: String, + account: Account, + ) { + val followListEvent = getPeopleList(identifierTag) + + val template = + followListEvent.update { + removePerson(user.pubkeyHex) + } + + val newList = signer.sign(template) + + account.sendMyPublicAndPrivateOutbox(newList) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleList.kt index e1da41772..c0f1af20a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleList.kt @@ -30,6 +30,7 @@ data class PeopleList( val identifierTag: String, val title: String, val description: String?, + val image: String?, val privateMembers: Set = emptySet(), val publicMembers: Set = emptySet(), ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt index fe4e4263e..7dfd32898 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt @@ -32,9 +32,14 @@ import com.vitorpamplona.amethyst.model.filter import com.vitorpamplona.amethyst.model.updateFlow import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.update import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip51Lists.followList.description import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.description +import com.vitorpamplona.quartz.nip51Lists.peopleList.image +import com.vitorpamplona.quartz.nip51Lists.peopleList.name import com.vitorpamplona.quartz.utils.flattenToSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -116,6 +121,7 @@ class PeopleListsState( identifierTag = this.dTag(), title = this.nameOrTitle() ?: this.dTag(), description = this.description(), + image = this.image(), privateMembers = cache.load(decryptionCache.privateUserIdSet(this)), publicMembers = cache.load(this.publicUsersIdSet()), ) @@ -129,17 +135,17 @@ class PeopleListsState( .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, emptyList()) - fun selectListFlow(selectedDTag: String) = + fun List.select(dTag: String) = + this.firstOrNull { + it.identifierTag == dTag + } + + fun selectList(dTag: String) = uiListFlow.value.select(dTag) + + fun selectListFlow(dTag: String) = uiListFlow - .map { peopleLists -> - peopleLists.firstOrNull { list -> - list.identifierTag == selectedDTag - } - }.onStart { - emit( - uiListFlow.value.firstOrNull { it.identifierTag == selectedDTag }, - ) - } + .map { it.select(dTag) } + .onStart { emit(selectList(dTag)) } fun DeletionEvent.hasDeletedAnyPeopleList() = deleteAddressesWithKind(PeopleListEvent.KIND) || deletesAnyEventIn(peopleListsEventIds.value) @@ -183,49 +189,48 @@ class PeopleListsState( suspend fun addFollowList( listName: String, listDescription: String?, + listImage: String?, member: User? = null, isPrivate: Boolean = false, account: Account, - ) { - val newList = - PeopleListEvent.createListWithDescription( - dTag = UUID.randomUUID().toString(), - title = listName, - description = listDescription, + ): String { + val dTag = UUID.randomUUID().toString() + val newListTemplate = + PeopleListEvent.build( + dTag = dTag, + name = listName, publicMembers = if (!isPrivate && member != null) listOf(member.toUserTag()) else emptyList(), privateMembers = if (isPrivate && member != null) listOf(member.toUserTag()) else emptyList(), signer = account.signer, - ) + ) { + if (listDescription != null) description(listDescription) + if (listImage != null) image(listImage) + } + + val newList = signer.sign(newListTemplate) + account.sendMyPublicAndPrivateOutbox(newList) + return dTag } - suspend fun renameFollowList( - newName: String, + suspend fun updateMetadata( + listName: String?, + listDescription: String?, + listImage: String?, peopleList: PeopleList, account: Account, ) { val listEvent = getPeopleList(peopleList.identifierTag) - val newList = - PeopleListEvent.modifyListName( - earlierVersion = listEvent, - newName = newName, - signer = account.signer, - ) - account.sendMyPublicAndPrivateOutbox(newList) - } - suspend fun modifyFollowSetDescription( - newDescription: String?, - peopleList: PeopleList, - account: Account, - ) { - val listEvent = getPeopleList(peopleList.identifierTag) - val newList = - PeopleListEvent.modifyDescription( - earlierVersion = listEvent, - newDescription = newDescription, - signer = account.signer, - ) + val template = + listEvent.update { + if (listName != null) name(listName) + if (listDescription != null) description(listDescription) + if (listImage != null) image(listImage) + } + + val newList = signer.sign(template) + account.sendMyPublicAndPrivateOutbox(newList) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt index c3f8f69ef..de9d963e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt @@ -74,7 +74,7 @@ class BlossomServerListState( return if (serverList != null && serverList.tags.isNotEmpty()) { BlossomServersEvent.updateRelayList( earlierVersion = serverList, - relays = servers, + servers = servers, signer = signer, ) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt index 89db92684..a9cd1a484 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -24,10 +24,13 @@ import com.vitorpamplona.amethyst.model.ALL_FOLLOWS import com.vitorpamplona.amethyst.model.ALL_USER_FOLLOWS import com.vitorpamplona.amethyst.model.AROUND_ME import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS +import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.Kind3UserFollowsFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.AroundMeFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow @@ -42,6 +45,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn @@ -49,6 +53,7 @@ import kotlinx.coroutines.flow.transformLatest class FeedTopNavFilterState( val feedFilterListName: MutableStateFlow, + val kind3Follows: StateFlow, val allFollows: StateFlow, val locationFlow: StateFlow, val followsRelays: StateFlow>, @@ -63,6 +68,7 @@ class FeedTopNavFilterState( GLOBAL_FOLLOWS -> GlobalFeedFlow(followsRelays, proxyRelays) ALL_FOLLOWS -> AllFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays) ALL_USER_FOLLOWS -> AllUserFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays) + KIND3_FOLLOWS -> Kind3UserFollowsFeedFlow(kind3Follows, followsRelays, blockedRelays, proxyRelays) AROUND_ME -> AroundMeFeedFlow(locationFlow, followsRelays, proxyRelays) else -> { val note = LocalCache.checkGetOrCreateAddressableNote(listName) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allUserFollows/Kind3UserFollowsFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allUserFollows/Kind3UserFollowsFeedFlow.kt new file mode 100644 index 000000000..ebf0967b8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allUserFollows/Kind3UserFollowsFeedFlow.kt @@ -0,0 +1,69 @@ +/** + * 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.model.topNavFeeds.allUserFollows + +import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine + +class Kind3UserFollowsFeedFlow( + val allFollows: StateFlow, + val followsRelays: StateFlow>, + val blockedRelays: StateFlow>, + val proxyRelays: StateFlow>, +) : IFeedFlowsType { + fun convert( + allFollows: Kind3FollowListState.Kind3Follows?, + proxyRelays: Set, + ): IFeedTopNavFilter = + if (allFollows != null) { + if (proxyRelays.isEmpty()) { + AllUserFollowsByOutboxTopNavFilter( + authors = allFollows.authors, + defaultRelays = followsRelays, + blockedRelays = blockedRelays, + ) + } else { + AllUserFollowsByProxyTopNavFilter( + authors = allFollows.authors, + proxyRelays = proxyRelays, + ) + } + } else { + AllUserFollowsByOutboxTopNavFilter( + authors = emptySet(), + defaultRelays = followsRelays, + blockedRelays = blockedRelays, + ) + } + + override fun flow() = combine(allFollows, proxyRelays, ::convert) + + override fun startValue(): IFeedTopNavFilter = convert(allFollows.value, proxyRelays.value) + + override suspend fun startValue(collector: FlowCollector) { + collector.emit(startValue()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt index c08ec6308..dfcac00c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.cashu.CashuToken import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.utils.asTextOrNull import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType @@ -127,7 +128,7 @@ class MeltProcessor { val msg = tree ?.get("detail") - ?.asText() + ?.asTextOrNull() ?.split('.') ?.getOrNull(0) ?.ifBlank { null } @@ -203,7 +204,7 @@ class MeltProcessor { val msg = tree ?.get("detail") - ?.asText() + ?.asTextOrNull() ?.split('.') ?.getOrNull(0) ?.ifBlank { null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt index 079fcd01d..a91a08a84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt @@ -21,11 +21,14 @@ package com.vitorpamplona.amethyst.service.relayClient import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.EventCollector import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayInsertConfirmationCollector import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent class CacheClientConnector( val client: INostrClient, @@ -50,5 +53,39 @@ class CacheClientConnector( private fun markAsSeen( eventId: HexKey, info: NormalizedRelayUrl, - ) = LocalCache.getNoteIfExists(eventId)?.addRelay(info) + ) { + val note = LocalCache.getNoteIfExists(eventId) + if (note != null) { + note.addRelay(info) + markAsSeenInner(note, info) + } + } + + private fun markAsSeenInner( + note: Note, + info: NormalizedRelayUrl, + ) { + val noteEvent = note.event + if (noteEvent is GiftWrapEvent) { + val innerEvent = noteEvent.innerEventId + if (innerEvent != null) { + val innerNote = cache.getNoteIfExists(innerEvent) + if (innerNote != null) { + innerNote.addRelay(info) + markAsSeenInner(innerNote, info) + } + } + } + + if (noteEvent is SealedRumorEvent) { + val innerEvent = noteEvent.innerEventId + if (innerEvent != null) { + val innerNote = cache.getNoteIfExists(innerEvent) + if (innerNote != null) { + innerNote.addRelay(info) + markAsSeenInner(innerNote, info) + } + } + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 88c902298..0265f6603 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dataso import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler @@ -77,6 +78,7 @@ class RelaySubscriptionsCoordinator( val profile = UserProfileFilterAssembler(client) val hashtags = HashtagFilterAssembler(client) val geohashes = GeoHashFilterAssembler(client) + val followPacks = FollowPackFeedFilterAssembler(client) // active when sending zaps via NWC val nwc = NWCPaymentFilterAssembler(client) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt index d232a84fc..50cdad965 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt @@ -107,7 +107,7 @@ fun observeNoteAndMap( @OptIn(ExperimentalCoroutinesApi::class) @Suppress("UNCHECKED_CAST") @Composable -fun observeNoteEventAndMap( +fun observeNoteEventAndMapNotNull( note: Note, accountViewModel: AccountViewModel, map: (T) -> U, @@ -130,6 +130,32 @@ fun observeNoteEventAndMap( return flow.collectAsStateWithLifecycle((note.event as? T)?.let { map(it) }) } +@OptIn(ExperimentalCoroutinesApi::class) +@Suppress("UNCHECKED_CAST") +@Composable +fun observeNoteEventAndMap( + note: Note, + accountViewModel: AccountViewModel, + map: (T?) -> U, +): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(note) { + note + .flow() + .metadata.stateFlow + .mapLatest { map(it.note.event as? T) } + .distinctUntilChanged() + .flowOn(Dispatchers.IO) + } + + // Subscribe in the LocalCache for changes that arrive in the device + return flow.collectAsStateWithLifecycle(map(note.event as? T)) +} + @OptIn(ExperimentalCoroutinesApi::class) @Composable fun observeNoteHasEvent( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index cba50204a..8387333de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -110,7 +110,7 @@ fun EditPostView( nav: INav, ) { val postViewModel: EditPostViewModel = viewModel() - postViewModel.prepare(edit, versionLookingAt, accountViewModel) + postViewModel.init(accountViewModel) val context = LocalContext.current @@ -118,7 +118,7 @@ fun EditPostView( val scope = rememberCoroutineScope() LaunchedEffect(Unit) { - postViewModel.load(edit, versionLookingAt, accountViewModel) + postViewModel.load(edit, versionLookingAt) } Dialog( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index 5d16b6183..0018215d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -61,13 +61,12 @@ import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.size import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Stable open class EditPostViewModel : ViewModel() { - var accountViewModel: AccountViewModel? = null - var account: Account? = null + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account var editedFromNote: Note? = null @@ -94,46 +93,32 @@ open class EditPostViewModel : ViewModel() { var canAddInvoice by mutableStateOf(false) var wantsInvoice by mutableStateOf(false) - open fun prepare( - edit: Note, - versionLookingAt: Note?, - accountViewModel: AccountViewModel, - ) { + open fun init(accountViewModel: AccountViewModel) { this.accountViewModel = accountViewModel this.account = accountViewModel.account - this.editedFromNote = edit - - this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountViewModel.account) } open fun load( edit: Note, versionLookingAt: Note?, - accountViewModel: AccountViewModel, ) { - this.accountViewModel = accountViewModel - this.account = accountViewModel.account - canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null multiOrchestrator = null message = TextFieldValue(versionLookingAt?.event?.content ?: edit.event?.content ?: "") urlPreview = findUrlInMessage() - editedFromNote = edit + this.editedFromNote = edit + + this.userSuggestions?.reset() + this.userSuggestions = UserSuggestionState(accountViewModel.account) } fun sendPost() { - viewModelScope.launch(Dispatchers.IO) { innerSendPost() } + accountViewModel.launchSigner(::innerSendPost) } suspend fun innerSendPost() { - if (accountViewModel == null) { - cancel() - return - } - val extraNotesToBroadcast = mutableListOf() nip95attachments.forEach { @@ -142,14 +127,14 @@ open class EditPostViewModel : ViewModel() { } val notify = - if (editedFromNote?.author?.pubkeyHex == account?.userProfile()?.pubkeyHex) { + if (editedFromNote?.author?.pubkeyHex == account.userProfile().pubkeyHex) { null } else { // notifies if it is not the logged in user editedFromNote?.author?.pubkeyHex } - account?.sendEdit( + account.sendEdit( message = message.text, originalNote = editedFromNote!!, notify = notify, @@ -191,7 +176,7 @@ open class EditPostViewModel : ViewModel() { context: Context, ) { viewModelScope.launch { - val myAccount = account ?: return@launch + val myAccount = account val myMultiOrchestrator = multiOrchestrator ?: return@launch isUploadingImage = true @@ -218,7 +203,7 @@ open class EditPostViewModel : ViewModel() { contentWarningReason = if (sensitiveContent) "" else null, ) nip95attachments = nip95attachments + nip95 - val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } + val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } note?.let { message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index bb6263bd9..139bf9587 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -102,7 +102,7 @@ class NewUserMetadataViewModel : ViewModel() { fun create() { // Tries to not delete any existing attribute that we do not work with. - accountViewModel.runIOCatching { + accountViewModel.launchSigner { val metadata = account.userMetadata.sendNewUserMetadata( name = displayName.value, @@ -145,7 +145,7 @@ class NewUserMetadataViewModel : ViewModel() { context: Context, onError: (String, String) -> Unit, ) { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { upload( uri, context, @@ -161,7 +161,7 @@ class NewUserMetadataViewModel : ViewModel() { context: Context, onError: (String, String) -> Unit, ) { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { upload( uri, context, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt index 587fab25f..5639688de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt @@ -52,9 +52,12 @@ fun AllMediaServersScreen( val nip96ServersViewModel: NIP96ServersViewModel = viewModel() val blossomServersViewModel: BlossomServersViewModel = viewModel() - LaunchedEffect(key1 = Unit) { - nip96ServersViewModel.load(accountViewModel.account) - blossomServersViewModel.load(accountViewModel.account) + nip96ServersViewModel.init(accountViewModel) + blossomServersViewModel.init(accountViewModel) + + LaunchedEffect(key1 = accountViewModel) { + nip96ServersViewModel.load() + blossomServersViewModel.load() } MediaServersScaffold(nip96ServersViewModel, blossomServersViewModel) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt index 4c223bf1b..c230ec35e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt @@ -23,23 +23,28 @@ package com.vitorpamplona.amethyst.ui.actions.mediaServers import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Rfc3986 -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class BlossomServersViewModel : ViewModel() { - lateinit var account: Account + private lateinit var accountViewModel: AccountViewModel + private lateinit var account: Account private val _fileServers = MutableStateFlow>(emptyList()) val fileServers = _fileServers.asStateFlow() private var isModified = false - fun load(account: Account) { - this.account = account + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account + } + + fun load() { refresh() } @@ -102,7 +107,7 @@ class BlossomServersViewModel : ViewModel() { serverUrl: String, ) { viewModelScope.launch { - val serverName = if (name.isNotBlank()) name else Rfc3986.host(serverUrl) + val serverName = name.ifBlank { Rfc3986.host(serverUrl) } _fileServers.update { it.minus( ServerName(serverName, serverUrl, ServerType.Blossom), @@ -119,7 +124,7 @@ class BlossomServersViewModel : ViewModel() { fun saveFileServers() { if (isModified) { - viewModelScope.launch(Dispatchers.IO) { + accountViewModel.launchSigner { val serverList = _fileServers.value.map { it.baseUrl } account.sendBlossomServersList(serverList) refresh() @@ -127,5 +132,5 @@ class BlossomServersViewModel : ViewModel() { } } - private fun obtainFileServers(): List? = account.blossomServers.flow.value + private fun obtainFileServers(): List = account.blossomServers.flow.value } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt index 641b7be15..1523e75f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt @@ -23,23 +23,28 @@ package com.vitorpamplona.amethyst.ui.actions.mediaServers import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Rfc3986 -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class NIP96ServersViewModel : ViewModel() { - lateinit var account: Account + private lateinit var accountViewModel: AccountViewModel + private lateinit var account: Account private val _fileServers = MutableStateFlow>(emptyList()) val fileServers = _fileServers.asStateFlow() private var isModified = false - fun load(account: Account) { - this.account = account + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account + } + + fun load() { refresh() } @@ -114,7 +119,7 @@ class NIP96ServersViewModel : ViewModel() { fun saveFileServers() { if (isModified) { - viewModelScope.launch(Dispatchers.IO) { + accountViewModel.launchSigner { val serverList = _fileServers.value.map { it.baseUrl } account.sendFileServersList(serverList) refresh() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index 2c901159e..f0c758bf3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -112,7 +112,7 @@ fun LoadOrCreateNote( if (note == null) { LaunchedEffect(key1 = event.id) { - accountViewModel.checkGetOrCreateNote(event) { note = it } + note = accountViewModel.noteFromEvent(event) } } @@ -254,7 +254,7 @@ fun DisplayUser( if (userBase == null) { LaunchedEffect(key1 = userHex) { - accountViewModel.checkGetOrCreateUser(userHex) { userBase = it } + userBase = accountViewModel.checkGetOrCreateUser(userHex) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index ce7e7ea98..fe52b0f61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -192,11 +192,11 @@ fun RenderRegularPreview() { "", 1, route = Route.EventRedirect(word.segmentText), - nav = EmptyNav, + nav = EmptyNav(), ) } - is HashTagSegment -> HashTag(word, EmptyNav) + is HashTagSegment -> HashTag(word, EmptyNav()) // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -225,7 +225,7 @@ fun RenderRegularPreview2() { is EmailSegment -> ClickableEmail(word.segmentText) is PhoneSegment -> ClickablePhone(word.segmentText) // is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, EmptyNav) + is HashTagSegment -> HashTag(word, EmptyNav()) // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -267,7 +267,7 @@ fun RenderRegularPreview3() { is EmailSegment -> ClickableEmail(word.segmentText) is PhoneSegment -> ClickablePhone(word.segmentText) // is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, EmptyNav) + is HashTagSegment -> HashTag(word, EmptyNav()) // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -746,7 +746,7 @@ fun LoadNote( if (note == null) { LaunchedEffect(key1 = baseNoteHex) { - accountViewModel.checkGetOrCreateNote(baseNoteHex) { note = it } + note = accountViewModel.checkGetOrCreateNote(baseNoteHex) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt index 47f05e6ec..223431553 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt @@ -122,7 +122,7 @@ fun RenderContentAsMarkdown( fun RenderContentAsMarkdownPreview() { val accountViewModel = mockAccountViewModel() - val nav = EmptyNav + val nav = EmptyNav() ThemeComparisonRow { val background = MaterialTheme.colorScheme.background @@ -174,7 +174,7 @@ fun RenderContentAsMarkdownPreview() { fun RenderContentAsMarkdownListsPreview() { val accountViewModel = mockAccountViewModel() - val nav = EmptyNav + val nav = EmptyNav() ThemeComparisonRow { val background = MaterialTheme.colorScheme.background @@ -224,7 +224,7 @@ fun RenderContentAsMarkdownListsPreview() { fun RenderContentAsMarkdownCodePreview() { val accountViewModel = mockAccountViewModel() - val nav = EmptyNav + val nav = EmptyNav() ThemeComparisonRow { val background = MaterialTheme.colorScheme.background @@ -278,7 +278,7 @@ fun RenderContentAsMarkdownCodePreview() { fun RenderContentAsMarkdownTablesPreview() { val accountViewModel = mockAccountViewModel() - val nav = EmptyNav + val nav = EmptyNav() ThemeComparisonRow { val background = MaterialTheme.colorScheme.background @@ -319,7 +319,7 @@ fun RenderContentAsMarkdownTablesPreview() { fun RenderContentAsMarkdownFootNotesPreview() { val accountViewModel = mockAccountViewModel() - val nav = EmptyNav + val nav = EmptyNav() ThemeComparisonRow { val background = MaterialTheme.colorScheme.background @@ -356,7 +356,7 @@ fun RenderContentAsMarkdownFootNotesPreview() { fun RenderContentAsMarkdownUserPreview() { val accountViewModel = mockAccountViewModel() - val nav = EmptyNav + val nav = EmptyNav() runBlocking { withContext(Dispatchers.IO) { @@ -418,7 +418,7 @@ fun RenderContentAsMarkdownUserPreview() { fun RenderContentAsMarkdownNotePreview() { val accountViewModel = mockAccountViewModel() - val nav = EmptyNav + val nav = EmptyNav() runBlocking { withContext(Dispatchers.IO) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/ErrorList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/ErrorList.kt index 7132fc87a..5950caf61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/ErrorList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/ErrorList.kt @@ -90,7 +90,7 @@ fun ErrorListPreview() { ErrorList( model = model, accountViewModel = accountViewModel, - nav = EmptyNav, + nav = EmptyNav(), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/MultiUserErrorMessageDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/MultiUserErrorMessageDialog.kt index 720b0786d..a296c5dc6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/MultiUserErrorMessageDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/MultiUserErrorMessageDialog.kt @@ -49,7 +49,7 @@ import kotlinx.coroutines.withContext @Preview fun MultiUserErrorMessageContentPreview() { val accountViewModel = mockAccountViewModel() - val nav = EmptyNav + val nav = EmptyNav() var user1: User? = null var user2: User? = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index b35f388b0..b3d2671ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -72,15 +72,19 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPackFeedScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.PeopleListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists.PeopleListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.packs.FollowPackScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.ListOfPeopleListsScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit.EditPeopleListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.FollowPackMetadataScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.PeopleListMetadataScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit.FollowListAndPackAndUserScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen @@ -126,8 +130,12 @@ fun AppNavigation( composable { NotificationScreen(accountViewModel, nav) } composableFromEnd { ListOfPeopleListsScreen(accountViewModel, nav) } - composableFromEndArgs { PeopleListScreen(it.dTag, accountViewModel, nav) } - composableFromBottomArgs { EditPeopleListScreen(it.userToAdd, accountViewModel, nav) } + composableFromEndArgs { PeopleListScreen(it.dTag, accountViewModel, nav) } + composableFromEndArgs { FollowPackScreen(it.dTag, accountViewModel, nav) } + composableFromBottomArgs { FollowListAndPackAndUserScreen(it.userToAdd, accountViewModel, nav) } + + composableFromBottomArgs { PeopleListMetadataScreen(it.dTag, accountViewModel, nav) } + composableFromBottomArgs { FollowPackMetadataScreen(it.dTag, accountViewModel, nav) } composableFromBottomArgs { NewUserMetadataScreen(nav, accountViewModel) } composable { SearchScreen(accountViewModel, nav) } @@ -149,6 +157,7 @@ fun AppNavigation( composableFromEndArgs { GeoHashScreen(it, accountViewModel, nav) } composableFromEndArgs { RelayInformationScreen(it.url, accountViewModel, nav) } composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEndArgs { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 1f309d01c..a8604e363 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -516,7 +516,7 @@ fun ListContent( NavigationRow( title = R.string.user_preferences, - icons = listOf(Icons.Outlined.Translate), + icon = Icons.Outlined.Translate, tint = MaterialTheme.colorScheme.onBackground, nav = nav, route = Route.UserSettings, @@ -599,27 +599,10 @@ fun NavigationRow( tint: Color, nav: INav, route: Route, -) { - NavigationRow( - title = title, - icons = listOf(icon), - tint = tint, - nav = nav, - route = route, - ) -} - -@Composable -fun NavigationRow( - title: Int, - icons: List, - tint: Color, - nav: INav, - route: Route, ) { IconRow( title = title, - icons = icons, + icon = icon, tint = tint, onClick = { nav.closeDrawer() @@ -669,21 +652,6 @@ fun IconRow( icon: ImageVector, tint: Color, onClick: () -> Unit, -) { - IconRow( - title = title, - icons = listOf(icon), - tint = tint, - onClick = onClick, - ) -} - -@Composable -fun IconRow( - title: Int, - icons: List, - tint: Color, - onClick: () -> Unit, ) { Row( modifier = @@ -698,14 +666,12 @@ fun IconRow( modifier = IconRowModifier, verticalAlignment = Alignment.CenterVertically, ) { - icons.forEach { icon -> - Icon( - imageVector = icon, - contentDescription = stringRes(title), - modifier = Size22Modifier.padding(end = 4.dp), - tint = tint, - ) - } + Icon( + imageVector = icon, + contentDescription = stringRes(title), + modifier = Size22Modifier.padding(end = 4.dp), + tint = tint, + ) Text( modifier = IconRowTextModifier, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/EmptyNav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/EmptyNav.kt index ed2cef42d..a90cfa939 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/EmptyNav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/EmptyNav.kt @@ -29,7 +29,7 @@ import kotlinx.coroutines.runBlocking import kotlin.reflect.KClass @Stable -object EmptyNav : INav { +class EmptyNav : INav { override val navigationScope: CoroutineScope get() = TODO("Not yet implemented") override val drawerState = DrawerState(DrawerValue.Closed) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index c69bc5e63..374c68d92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent @@ -65,73 +66,82 @@ fun routeFor( fun routeFor( noteEvent: Event, loggedIn: Account, -): Route? { +): Route? = if (noteEvent is DraftWrapEvent) { val innerEvent = loggedIn.draftsDecryptionCache.preCachedDraft(noteEvent) - if (innerEvent is IsInPublicChatChannel) { - innerEvent.channelId()?.let { - return Route.PublicChatChannel(it) - } - } else if (innerEvent is LiveActivitiesEvent) { - innerEvent.address().let { - return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag) - } - } else if (innerEvent is LiveActivitiesChatMessageEvent) { - innerEvent.activityAddress()?.let { - return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag) - } - } else if (innerEvent is EphemeralChatEvent) { - innerEvent.roomId()?.let { - return Route.EphemeralChat(it.id, it.relayUrl.url) - } - } else if (innerEvent is ChatroomKeyable) { - val room = innerEvent.chatroomKey(loggedIn.userProfile().pubkeyHex) - loggedIn.chatroomList.getOrCreatePrivateChatroom(room) - return Route.Room(room) - } else if (innerEvent is AddressableEvent) { - return Route.Note(noteEvent.aTag().toTag()) + if (innerEvent != null) { + routeForInner(innerEvent, loggedIn) } else { - return Route.Note(noteEvent.id) + Route.Note(noteEvent.id) } - } else if (noteEvent is AppDefinitionEvent) { - return Route.ContentDiscovery(noteEvent.id) - } else if (noteEvent is IsInPublicChatChannel) { - noteEvent.channelId()?.let { - return Route.PublicChatChannel(it) - } - } else if (noteEvent is ChannelCreateEvent) { - return Route.PublicChatChannel(noteEvent.id) - } else if (noteEvent is LiveActivitiesEvent) { - noteEvent.address().let { - return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag) - } - } else if (noteEvent is LiveActivitiesChatMessageEvent) { - noteEvent.activityAddress()?.let { - return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag) - } - } else if (noteEvent is ChatroomKeyable) { - val room = noteEvent.chatroomKey(loggedIn.userProfile().pubkeyHex) - loggedIn.chatroomList.getOrCreatePrivateChatroom(room) - return Route.Room(room) - } else if (noteEvent is CommunityDefinitionEvent) { - return Route.Community(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag()) - } else if (noteEvent is GiftWrapEvent) { - noteEvent.innerEventId?.let { - return routeFor(LocalCache.getOrCreateNote(it), loggedIn) - } - } else if (noteEvent is SealedRumorEvent) { - noteEvent.innerEventId?.let { - return routeFor(LocalCache.getOrCreateNote(it), loggedIn) - } - } else if (noteEvent is AddressableEvent) { - return Route.Note(noteEvent.aTag().toTag()) } else { - return Route.Note(noteEvent.id) + routeForInner(noteEvent, loggedIn) } - return null -} +fun routeForInner( + noteEvent: Event, + loggedIn: Account, +): Route? = + when (noteEvent) { + is AppDefinitionEvent -> { + Route.ContentDiscovery(noteEvent.id) + } + is IsInPublicChatChannel -> { + noteEvent.channelId()?.let { + Route.PublicChatChannel(it) + } + } + is ChannelCreateEvent -> { + Route.PublicChatChannel(noteEvent.id) + } + is LiveActivitiesEvent -> { + noteEvent.address().let { + Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag) + } + } + is LiveActivitiesChatMessageEvent -> { + noteEvent.activityAddress()?.let { + Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag) + } + } + is EphemeralChatEvent -> { + noteEvent.roomId()?.let { + Route.EphemeralChat(it.id, it.relayUrl.url) + } + } + + is FollowListEvent -> { + Route.FollowPack(noteEvent.address()) + } + + is ChatroomKeyable -> { + val room = noteEvent.chatroomKey(loggedIn.userProfile().pubkeyHex) + loggedIn.chatroomList.getOrCreatePrivateChatroom(room) + Route.Room(room) + } + + is CommunityDefinitionEvent -> { + Route.Community(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag()) + } + is GiftWrapEvent -> { + noteEvent.innerEventId?.let { + routeFor(LocalCache.getOrCreateNote(it), loggedIn) + } + } + is SealedRumorEvent -> { + noteEvent.innerEventId?.let { + routeFor(LocalCache.getOrCreateNote(it), loggedIn) + } + } + is AddressableEvent -> { + Route.Note(noteEvent.aTag().toTag()) + } + + else -> { + Route.Note(noteEvent.id) + } + } fun routeToMessage( user: HexKey, @@ -207,6 +217,8 @@ fun routeFor(roomId: RoomId): Route = Route.EphemeralChat(roomId.id, roomId.rela fun routeFor(user: User): Route.Profile = Route.Profile(user.pubkeyHex) +fun routeForUser(userHex: HexKey): Route.Profile = Route.Profile(userHex) + fun authorRouteFor(note: Note): Route.Profile? = note.author?.pubkeyHex?.let { Route.Profile(it) } fun routeReplyTo( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index fa30ee8b2..778073e83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.navigation.routes import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavHostController import androidx.navigation.toRoute +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import kotlinx.serialization.Serializable @@ -54,10 +55,22 @@ sealed class Route { @Serializable object Lists : Route() - @Serializable data class PeopleListView( + @Serializable data class MyPeopleListView( val dTag: String, ) : Route() + @Serializable data class MyFollowPackView( + val dTag: String, + ) : Route() + + @Serializable data class PeopleListMetadataEdit( + val dTag: String? = null, + ) : Route() + + @Serializable data class FollowPackMetadataEdit( + val dTag: String? = null, + ) : Route() + @Serializable data class PeopleListManagement( val userToAdd: HexKey, ) : Route() @@ -96,7 +109,25 @@ sealed class Route { val kind: Int, val pubKeyHex: HexKey, val dTag: String, - ) : Route() + ) : Route() { + constructor(address: Address) : this( + kind = address.kind, + pubKeyHex = address.pubKeyHex, + dTag = address.dTag, + ) + } + + @Serializable data class FollowPack( + val kind: Int, + val pubKeyHex: HexKey, + val dTag: String, + ) : Route() { + constructor(address: Address) : this( + kind = address.kind, + pubKeyHex = address.pubKeyHex, + dTag = address.dTag, + ) + } @Serializable data class PublicChatChannel( val id: String, @@ -271,6 +302,15 @@ fun getRouteWithArguments(navController: NavHostController): Route? { dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + else -> { null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt index 35497746e..d472fa4b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt @@ -95,16 +95,13 @@ fun BlankNote( @Composable @Preview fun HiddenNotePreview() { - val accountViewModel = mockAccountViewModel() - val nav = EmptyNav - ThemeComparisonColumn( toPreview = { HiddenNote( reports = persistentSetOf(), isHiddenAuthor = true, - accountViewModel = accountViewModel, - nav = nav, + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), ) {} }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Gallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Gallery.kt index 25f1c7e15..3bbf5b382 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Gallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Gallery.kt @@ -38,8 +38,10 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.navigation.routes.routeForUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.ImmutableList @OptIn(ExperimentalLayoutApi::class) @@ -81,3 +83,45 @@ fun Gallery( } } } + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun GalleryUnloaded( + users: ImmutableList, + modifier: Modifier, + accountViewModel: AccountViewModel, + nav: INav, + maxPictures: Int = 6, +) { + FlowRow( + modifier, + verticalArrangement = Arrangement.Center, + horizontalArrangement = Arrangement.spacedBy((-5).dp), + ) { + users.take(maxPictures).forEach { + ClickableUserPicture( + it, + Size25dp, + accountViewModel, + onClick = { + nav.nav { + routeForUser(it) + } + }, + ) + } + + if (users.size > maxPictures) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(Size25dp).clip(shape = CircleShape).background(MaterialTheme.colorScheme.secondaryContainer), + ) { + Text( + text = "+" + showCount(users.size - maxPictures), + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 6e9f9fa58..8442ffeed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -708,7 +708,7 @@ private fun RenderNoteRow( is BadgeAwardEvent -> RenderBadgeAward(baseNote, backgroundColor, accountViewModel, nav) is FhirResourceEvent -> RenderFhirResource(baseNote, accountViewModel, nav) is PeopleListEvent -> DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav) - is FollowListEvent -> DisplayFollowList(baseNote, backgroundColor, accountViewModel, nav) + is FollowListEvent -> DisplayFollowList(baseNote, true, accountViewModel, nav) is RelaySetEvent -> DisplayRelaySet(baseNote, backgroundColor, accountViewModel, nav) is ChatMessageRelayListEvent -> DisplayDMRelayList(baseNote, backgroundColor, accountViewModel, nav) is AdvertisedRelayListEvent -> DisplayNIP65RelayList(baseNote, backgroundColor, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index cf5d6d14e..22183a391 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -151,7 +151,7 @@ fun PollNotePreview() { ) val accountViewModel = mockVitorAccountViewModel() - val nav = EmptyNav + val nav = EmptyNav() val baseNote: Note? runBlocking { @@ -205,7 +205,7 @@ fun PollNotePreview2() { ) val accountViewModel = mockAccountViewModel() - val nav = EmptyNav + val nav = EmptyNav() val baseNote: Note? runBlocking { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt index 27bfd4599..881675ffe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt @@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size17Modifier import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.noteComposeRelayBox import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.coroutines.flow.mapNotNull @Composable fun RelayBadges( @@ -122,12 +123,14 @@ fun WatchAndRenderRelay( accountViewModel: AccountViewModel, nav: INav, ) { - val noteRelays by baseNote + val relay by baseNote .flow() .relays.stateFlow - .collectAsStateWithLifecycle() + .mapNotNull { + it.note.relays.getOrNull(relayIndex) + }.collectAsStateWithLifecycle(baseNote.relays.getOrNull(relayIndex)) - CrossfadeIfEnabled(targetState = noteRelays.note.relays.getOrNull(relayIndex), label = "RenderRelay", modifier = Size17Modifier, accountViewModel = accountViewModel) { + CrossfadeIfEnabled(targetState = relay, label = "RenderRelay", modifier = Size17Modifier, accountViewModel = accountViewModel) { if (it != null) { RenderRelay(it, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt index c2ae95055..819b366c1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt @@ -77,7 +77,7 @@ import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.firstFullChar -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMapNotNull import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge @@ -370,7 +370,7 @@ private fun EmojiSelector( accountViewModel, ) { emptyNote -> emptyNote?.let { usersEmojiList -> - val collections by observeNoteEventAndMap(usersEmojiList, accountViewModel) { event: EmojiPackSelectionEvent -> + val collections by observeNoteEventAndMapNotNull(usersEmojiList, accountViewModel) { event: EmojiPackSelectionEvent -> event.emojiPacks().toImmutableList() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index 405c31996..4a243c8ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey @Composable @@ -175,6 +176,40 @@ fun ClickableUserPicture( BaseUserPicture(baseUser, size, accountViewModel, modifier, myModifier) } +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ClickableUserPicture( + baseUserHex: HexKey, + size: Dp, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, + onClick: ((HexKey) -> Unit)? = null, + onLongClick: ((HexKey) -> Unit)? = null, +) { + // BaseUser is the same reference as accountState.user + val myModifier = + remember(baseUserHex) { + if (onClick != null && onLongClick != null) { + Modifier + .size(size) + .combinedClickable( + onClick = { onClick(baseUserHex) }, + onLongClick = { onLongClick(baseUserHex) }, + ) + } else if (onClick != null) { + Modifier + .size(size) + .clickable( + onClick = { onClick(baseUserHex) }, + ) + } else { + Modifier.size(size) + } + } + + BaseUserPicture(baseUserHex, size, accountViewModel, modifier, myModifier) +} + @Composable fun NonClickableUserPictures( room: ChatroomKey, @@ -322,6 +357,34 @@ fun BaseUserPicture( } } +@Composable +fun BaseUserPicture( + baseUserHex: HexKey, + size: Dp, + accountViewModel: AccountViewModel, + innerModifier: Modifier = Modifier, + outerModifier: Modifier = Modifier.size(size), +) { + Box(outerModifier, contentAlignment = Alignment.TopEnd) { + LoadUserProfilePicture(baseUserHex, accountViewModel) { userProfilePicture, userName -> + InnerUserPicture( + userHex = baseUserHex, + userPicture = userProfilePicture, + userName = userName, + size = size, + modifier = innerModifier, + accountViewModel = accountViewModel, + ) + } + + WatchUserFollows(baseUserHex, accountViewModel) { newFollowingState -> + if (newFollowingState) { + FollowingIcon(Modifier.size(size.div(3.5f))) + } + } + } +} + @Composable fun LoadUserProfilePicture( baseUser: User, @@ -333,6 +396,23 @@ fun LoadUserProfilePicture( innerContent(userProfile?.profilePicture(), userProfile?.bestName()) } +@Composable +fun LoadUserProfilePicture( + baseUserHex: HexKey, + accountViewModel: AccountViewModel, + innerContent: @Composable (String?, String?) -> Unit, +) { + LoadUser(baseUserHex, accountViewModel) { + if (it != null) { + val userProfile by observeUserInfo(it, accountViewModel) + + innerContent(userProfile?.profilePicture(), userProfile?.bestName()) + } else { + innerContent(null, null) + } + } +} + @Composable fun InnerUserPicture( userHex: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt index 44388b352..27374f6a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt @@ -46,7 +46,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserAboutMe import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.nav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -104,7 +103,7 @@ fun RenderZapNotePreview() { user1, note1, accountViewModel, - EmptyNav, + EmptyNav(), ) } } @@ -122,7 +121,7 @@ fun RenderZapNoteSlimPreview() { user1, note1, accountViewModel, - EmptyNav, + EmptyNav(), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/draftTags/DraftTagState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/draftTags/DraftTagState.kt index 363f34e36..32634e40e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/draftTags/DraftTagState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/draftTags/DraftTagState.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.draftTags +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -30,6 +31,7 @@ import kotlinx.coroutines.flow.update import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid +@Stable class DraftTagState { var current: String by mutableStateOf(newTag()) var usedDraftTags by mutableStateOf(setOf(current)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/WatchAndLoadMyEmojiList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/WatchAndLoadMyEmojiList.kt index eb16733a7..b3f73a6da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/WatchAndLoadMyEmojiList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/WatchAndLoadMyEmojiList.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMapNotNull import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses @@ -37,7 +37,7 @@ fun WatchAndLoadMyEmojiList(accountViewModel: AccountViewModel) { accountViewModel, ) { emptyNote -> emptyNote?.let { usersEmojiList -> - val collections by observeNoteEventAndMap(usersEmojiList, accountViewModel) { event: EmojiPackSelectionEvent -> + val collections by observeNoteEventAndMapNotNull(usersEmojiList, accountViewModel) { event: EmojiPackSelectionEvent -> event.taggedAddresses().toImmutableList() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 5b9baefcd..cad37791d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.userSuggestions +import androidx.compose.runtime.Stable import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import com.vitorpamplona.amethyst.logTime @@ -37,6 +38,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +@Stable class UserSuggestionState( val account: Account, val requireAtSymbol: Boolean = true, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt index 45684c691..dc400a7d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt @@ -60,7 +60,7 @@ fun AddInboxRelayForDMCardPreview() { ThemeComparisonColumn { AddInboxRelayForDMCard( accountViewModel = mockAccountViewModel(), - nav = EmptyNav, + nav = EmptyNav(), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index de6bf1f60..6f5d1e758 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -138,7 +138,7 @@ fun GenericCommentPostScreen( WatchAndLoadMyEmojiList(accountViewModel) BackHandler { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() } @@ -152,7 +152,7 @@ fun GenericCommentPostScreen( onCancel = { // uses the accountViewModel scope to avoid cancelling this // function when the postViewModel is released - accountViewModel.runIOCatching { + accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() } @@ -161,7 +161,7 @@ fun GenericCommentPostScreen( onPost = { // uses the accountViewModel scope to avoid cancelling this // function when the postViewModel is released - accountViewModel.runIOCatching { + accountViewModel.launchSigner { postViewModel.sendPostSync() nav.popBack() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt index ec18a8c73..ea1beb2c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt @@ -21,139 +21,214 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap import com.vitorpamplona.amethyst.ui.components.MyAsyncImage -import com.vitorpamplona.amethyst.ui.components.ShowMoreButton +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.UserCompose +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.GalleryUnloaded import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground -import com.vitorpamplona.amethyst.ui.note.getGradient import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.FollowSetCard +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FollowSetImageModifier -import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds +import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp +import com.vitorpamplona.amethyst.ui.theme.StdPadding +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.blackTagModifier +import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList +import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList @OptIn(ExperimentalLayoutApi::class) @Composable fun DisplayFollowList( baseNote: Note, - backgroundColor: MutableState, + makeItShort: Boolean, accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = baseNote.event as? FollowListEvent ?: return - - var members by remember { mutableStateOf>(persistentListOf()) } - - var expanded by remember { mutableStateOf(false) } - - val toMembersShow = - if (expanded) { - members - } else { - members.take(3) - } - - val image = noteEvent.image() - - image?.let { - MyAsyncImage( - imageUrl = it, - contentDescription = - stringRes( - R.string.preview_card_image_for, - it, - ), - contentScale = ContentScale.Crop, - mainImageModifier = Modifier.fillMaxWidth(), - loadedImageModifier = FollowSetImageModifier, - accountViewModel = accountViewModel, - onLoadingBackground = { DefaultImageHeaderBackground(baseNote, accountViewModel) }, - onError = { DefaultImageHeader(baseNote, accountViewModel) }, - ) - } ?: run { - DefaultImageHeader(baseNote, accountViewModel, FollowSetImageModifier) - } - - Text( - text = noteEvent.title() ?: noteEvent.dTag(), - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .fillMaxWidth() - .padding(top = 10.dp), - textAlign = TextAlign.Center, - ) - - LaunchedEffect(Unit) { - accountViewModel.loadUsers(noteEvent.taggedUserIds()) { - members = it - } - } - - Box { - FlowRow(modifier = Modifier.padding(top = 5.dp)) { - toMembersShow.forEach { user -> - Column(modifier = Modifier.fillMaxWidth()) { - UserCompose( - user, - accountViewModel = accountViewModel, - nav = nav, - ) - - HorizontalDivider( - thickness = DividerThickness, - ) - } + val card = + observeNoteEventAndMap(baseNote, accountViewModel) { event: FollowListEvent? -> + if (event == null) { + FollowSetCard( + name = "", + media = "", + description = "", + users = persistentListOf(), + ) + } else { + FollowSetCard( + name = event.title()?.ifBlank { null } ?: event.dTag(), + media = event.image()?.ifBlank { null }, + description = event.description(), + users = accountViewModel.sortUsersSync(event.followIds()).toImmutableList(), + ) } } - if (members.size > 3 && !expanded) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background(getGradient(backgroundColor)), - ) { - ShowMoreButton { expanded = !expanded } + RenderFollowSetThumbEmbed( + card.value, + baseNote, + makeItShort, + accountViewModel, + nav, + ) +} + +@Composable +fun RenderFollowSetThumbEmbed( + card: FollowSetCard, + baseNote: Note, + makeItShort: Boolean, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column( + modifier = + Modifier.fillMaxWidth().clickable { + nav.nav { routeFor(baseNote, accountViewModel.account) } + }, + verticalArrangement = SpacedBy5dp, + ) { + Box( + contentAlignment = Alignment.BottomStart, + ) { + card.media?.let { + MyAsyncImage( + imageUrl = it, + contentDescription = stringRes(R.string.preview_card_image_for, it), + contentScale = ContentScale.Crop, + mainImageModifier = Modifier, + loadedImageModifier = FollowSetImageModifier, + accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(baseNote, accountViewModel) }, + onError = { DefaultImageHeader(baseNote, accountViewModel) }, + ) + } ?: run { DefaultImageHeader(baseNote, accountViewModel, FollowSetImageModifier) } + + GalleryUnloaded(card.users, StdPadding, accountViewModel, nav) + } + + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = card.name, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = stringRes(R.string.follow_list_item_label), + color = MaterialTheme.colorScheme.background, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = MaterialTheme.colorScheme.blackTagModifier, + ) + } + + if (!makeItShort) { + card.description?.let { + val defaultBackground = MaterialTheme.colorScheme.background + val background = remember { mutableStateOf(defaultBackground) } + + TranslatableRichTextViewer( + content = it, + canPreview = true, + quotesLeft = 2, + modifier = Modifier.fillMaxWidth(), + tags = baseNote.event?.tags?.toImmutableListOfLists() ?: EmptyTagList, + backgroundColor = background, + id = it, + callbackUri = null, + accountViewModel = accountViewModel, + nav = nav, + ) } } } } + +@Preview +@Composable +fun RenderFollowSetThumbPreview() { + val accountViewModel = mockAccountViewModel() + + val followCard = + FollowListEvent( + id = "eca31634fce7c9068b56fa8db9f387da70bdcceb3986a77ca1a9844f3128eb5f", + pubKey = "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da", + createdAt = 1761736286, + tags = + arrayOf( + arrayOf("title", "Retro Computer Fans"), + arrayOf("d", "xmbspe8rddsq"), + arrayOf("image", "https://blog.johnnovak.net/2022/04/15/achieving-period-correct-graphics-in-personal-computer-emulators-part-1-the-amiga/img/dream-setup.jpg"), + arrayOf("p", "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da"), + arrayOf("p", "9a9a4aa0e43e57873380ab22e8a3df12f3c4cf5bb3a804c6e3fed0069a6e2740"), + arrayOf("p", "4f5dd82517b11088ce00f23d99f06fe8f3e2e45ecf47bc9c2f90f34d5c6f7382"), + arrayOf("p", "ac92102a2ecb873c488e0125354ef5a97075a16198668c360eda050007ed42cd"), + arrayOf("p", "47f54409a4620eb35208a3bc1b53555bf3d0656b246bf0471a93208e20672f6f"), + arrayOf("p", "2624911545afb7a2b440cf10f5c69308afa33aae26fca664d8c94623dc0f1baf"), + arrayOf("p", "6641f26f5c59f7010dbe3e42e4593398e27c087497cb7d20e0e7633a17e48a94"), + arrayOf("description", "Retro computer fans and enthusiasts "), + ), + content = "", + sig = "3aa388edafad151e81cb0228fe04e115dbbcaa851c666bfe3c8740b6cd99575f0fc3ba2d47acda86f7626564a05e9dbc05ef452a7bd0ac00f828dbad0e1bae6c", + ) + + LocalCache.justConsume(followCard, null, false) + + val card = + FollowSetCard( + name = followCard.title()?.ifBlank { null } ?: followCard.dTag(), + media = followCard.image()?.ifBlank { null }, + description = followCard.description()?.ifBlank { null }, + users = followCard.followIds().toImmutableList(), + ) + + ThemeComparisonColumn { + RenderFollowSetThumbEmbed( + card = card, + baseNote = LocalCache.getOrCreateNote(followCard.id), + makeItShort = false, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt index 4f0e833b5..d6b55afac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.note.types import android.net.Uri import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxWidth @@ -36,6 +37,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -48,9 +50,12 @@ import com.vitorpamplona.amethyst.ui.components.DisplayEvent import com.vitorpamplona.amethyst.ui.components.RenderUserAsClickableText import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.measureSpaceWidth +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.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList import com.vitorpamplona.quartz.nip01Core.core.firstTagValueFor @@ -61,6 +66,7 @@ import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.net.URL +import java.util.UUID @Composable fun RenderHighlight( @@ -78,7 +84,7 @@ fun RenderHighlight( comment = noteEvent.comment(), highlight = noteEvent.quote(), context = noteEvent.context(), - authorHex = noteEvent.pubKey, + authorHex = noteEvent.author(), url = noteEvent.inUrl(), postAddress = noteEvent.inPostAddress(), postVersion = noteEvent.inPostVersion(), @@ -91,6 +97,54 @@ fun RenderHighlight( ) } +@Preview +@Composable +fun DisplayHighlightPreview() { + ThemeComparisonColumn { + Column { + DisplayHighlight( + comment = null, + highlight = "new architectures of freedom", + context = "He never wrote a line of cryptographic code and never lectured on Austrian economics. Yet the cultural terrain he helped seed, particularly the psychedelic, post-industrial counterculture of the 1960s and ’70s, became the moral and metaphysical groundwork from which new architectures of freedom would later emerge.", + authorHex = "eaa06714ac905aa5583860391e161edc7a815359b7c3e9b9b202c0558aefbeac", + url = null, + postAddress = Address(30023, "eaa06714ac905aa5583860391e161edc7a815359b7c3e9b9b202c0558aefbeac", "bitcoin-here-now"), + postVersion = null, + makeItShort = false, + canPreview = true, + quotesLeft = 3, + backgroundColor = mutableStateOf(Color.White), + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), + ) + } + } +} + +@Preview +@Composable +fun DisplayHighlightPreviewNewLine() { + ThemeComparisonColumn { + Column { + DisplayHighlight( + comment = null, + highlight = "He never wrote a line of cryptographic code and never lectured on Austrian economics.\nYet the cultural terrain he helped seed, particularly the psychedelic", + context = "He never wrote a line of cryptographic code and never lectured on Austrian economics.\nYet the cultural terrain he helped seed, particularly the psychedelic, post-industrial counterculture of the 1960s and ’70s, became the moral and metaphysical groundwork from which new architectures of freedom would later emerge.", + authorHex = "eaa06714ac905aa5583860391e161edc7a815359b7c3e9b9b202c0558aefbeac", + url = null, + postAddress = Address(30023, "eaa06714ac905aa5583860391e161edc7a815359b7c3e9b9b202c0558aefbeac", "bitcoin-here-now"), + postVersion = null, + makeItShort = false, + canPreview = true, + quotesLeft = 3, + backgroundColor = mutableStateOf(Color.White), + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), + ) + } + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable fun DisplayHighlight( @@ -124,8 +178,24 @@ fun DisplayHighlight( } val quote = - remember { - highlight.split("\n").joinToString("\n") { "> *${it.removeSuffix(" ")}*" } + remember(highlight) { + val uuid = UUID.randomUUID().toString() + if (context != null) { + if (context.contains(highlight)) { + val cleanContext = context.replace(highlight, uuid) + + val quotedContext = cleanContext.split("\n").joinToString("\n") { "> ${it.removeSuffix(" ")}" } + + val quotedSplit = highlight.split("\n") + val quotedHighlight = quotedSplit.joinToString("\n >") { "**${it.removeSuffix(" ")}**" } + + quotedContext.replace(uuid, quotedHighlight) + } else { + highlight.split("\n").joinToString("\n") { "> ${it.removeSuffix(" ")}" } + } + } else { + highlight.split("\n").joinToString("\n") { "> ${it.removeSuffix(" ")}" } + } } TranslatableRichTextViewer( @@ -173,9 +243,7 @@ private fun DisplayQuoteAuthor( if (userBase == null && authorHex != null) { LaunchedEffect(authorHex) { - accountViewModel.checkGetOrCreateUser(authorHex) { newUserBase -> - userBase = newUserBase - } + userBase = accountViewModel.checkGetOrCreateUser(authorHex) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt index 742441880..88ccdf5cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt @@ -89,7 +89,7 @@ import java.util.Locale fun RenderLiveActivityEventPreview() { val event = Event.fromJson("{\"id\":\"19406ad34ce3c653d62eb73c1816ac27dcf473c2ccdccf5af7d90d2633c62561\",\"pubkey\":\"6b66886b3add72c779d205be574ec2d7cec619061ac3e75717389b26445989e4\",\"created_at\":1719084750,\"kind\":30311,\"tags\":[[\"r\",\"podcast:guid:72d5e069-f907-5ee7-b0d7-45404f4f0aa5\"],[\"r\",\"podcast:item:guid:bfc33d6e-e00f-4f11-a2ff-94865b7867aa\"],[\"d\",\"bfc33d6e-e00f-4f11-a2ff-94865b7867aa\"],[\"title\",\"The Online Identity Time Bomb\"],[\"summary\",\"Online identity is a ticking time bomb. But are trustworthy, open-source solutions ready to disarm it, or will we be stuck with lackluster, proprietary systems?\\n Live chat: https:/jblive.tv\"],[\"streaming\",\"https://jblive.fm\"],[\"starts\",\"1719167400\"],[\"status\",\"planned\"],[\"image\",\"https://station.us-iad-1.linodeobjects.com/art/lup-mp3.jpg\"]],\"content\":\"\",\"sig\":\"2ce3fae9ad4512541aaae4dbd9484f50df62ab95ba935d7512b736f087f151c1d15ec2bf62d3135474f16c3e070f335b24349c5493461873ddca3051804ca944\"}") as LiveActivitiesEvent val accountViewModel = mockAccountViewModel() - val nav = EmptyNav + val nav = EmptyNav() val baseNote: Note? runBlocking { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt index 45d867a2d..8d818b56c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt @@ -38,6 +38,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -71,7 +72,7 @@ fun DisplayPeopleList( var members by remember { mutableStateOf>(persistentListOf()) } - var expanded by remember { mutableStateOf(false) } + var expanded by rememberSaveable { mutableStateOf(false) } val toMembersShow = if (expanded) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt index 53c24df27..3b4eedba0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt @@ -76,7 +76,7 @@ import kotlin.coroutines.cancellation.CancellationException @Composable fun TorrentPreview() { val accountViewModel = mockAccountViewModel() - val nav = EmptyNav + val nav = EmptyNav() val torrent = runBlocking { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt index d9d91d3fb..d102e605a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt @@ -69,7 +69,7 @@ import kotlinx.coroutines.withContext @Composable fun TorrentCommentPreview() { val accountViewModel = mockAccountViewModel() - val nav = EmptyNav + val nav = EmptyNav() val comment = runBlocking { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 3d6f89fc1..6cf7aea7d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.AROUND_ME import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS +import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.stringRes @@ -72,7 +73,7 @@ class TopNavFilterState( val account: Account, val scope: CoroutineScope, ) { - val kind3Follow = + val allFollows = PeopleListOutBoxFeedDefinition( code = ALL_FOLLOWS, name = ResourceName(R.string.follow_list_kind3follows), @@ -81,7 +82,7 @@ class TopNavFilterState( unpackList = listOf(ContactListEvent.blockListFor(account.signer.pubKey)), ) - val kind3FollowUsers = + val userFollows = PeopleListOutBoxFeedDefinition( code = ALL_USER_FOLLOWS, name = ResourceName(R.string.follow_list_kind3follows_users_only), @@ -90,6 +91,15 @@ class TopNavFilterState( unpackList = listOf(ContactListEvent.blockListFor(account.signer.pubKey)), ) + val kind3Follows = + PeopleListOutBoxFeedDefinition( + code = KIND3_FOLLOWS, + name = ResourceName(R.string.follow_list_kind3_follows_users_only), + type = CodeNameType.HARDCODED, + kinds = DEFAULT_FEED_KINDS, + unpackList = listOf(ContactListEvent.blockListFor(account.signer.pubKey)), + ) + val globalFollow = GlobalFeedDefinition( code = GLOBAL_FOLLOWS, @@ -115,7 +125,7 @@ class TopNavFilterState( unpackList = listOf(MuteListEvent.blockListFor(account.userProfile().pubkeyHex)), ) - val defaultLists = persistentListOf(kind3Follow, kind3FollowUsers, aroundMe, globalFollow, muteListFollow) + val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow) fun mergePeopleLists( peopleLists: List, @@ -229,7 +239,7 @@ class TopNavFilterState( checkNotInMainThread() emit( listOf( - listOf(kind3Follow, kind3FollowUsers, aroundMe, globalFollow), + listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow), myLivePeopleListsFlow, myLiveKind3FollowsFlow, listOf(muteListFollow), @@ -245,7 +255,7 @@ class TopNavFilterState( checkNotInMainThread() emit( listOf( - listOf(kind3Follow, kind3FollowUsers, aroundMe, globalFollow), + listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow), myLivePeopleListsFlow, listOf(muteListFollow), ).flatten().toImmutableList(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt index a89651c33..23f9aa2ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt @@ -21,12 +21,14 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty @@ -85,6 +87,7 @@ private fun FeedLoaded( val listState = rememberLazyListState() LazyColumn( + modifier = Modifier.fillMaxSize(), contentPadding = FeedPadding, state = listState, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 4f86b1000..66283da30 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -313,7 +313,7 @@ class AccountViewModel( note: Note, reaction: String, ) { - runIOCatching { + launchSigner { val currentReactions = note.allReactionsOfContentByAuthor(userProfile(), reaction) if (currentReactions.isNotEmpty()) { account.delete(currentReactions) @@ -679,7 +679,7 @@ class AccountViewModel( onProgress: (percent: Float) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, zapType: LnZapEvent.ZapType? = null, - ) = runIOCatching { + ) = launchSigner { ZapPaymentHandler(account).zap( note = note, amountMilliSats = amountInMillisats, @@ -699,23 +699,23 @@ class AccountViewModel( note: Note, type: ReportType, content: String = "", - ) = runIOCatching { account.report(note, type, content) } + ) = launchSigner { account.report(note, type, content) } fun report( user: User, type: ReportType, ) { - runIOCatching { + launchSigner { account.report(user, type) account.hideUser(user.pubkeyHex) } } - fun boost(note: Note) = runIOCatching { account.boost(note) } + fun boost(note: Note) = launchSigner { account.boost(note) } - fun removeEmojiPack(emojiPack: Note) = runIOCatching { account.removeEmojiPack(emojiPack) } + fun removeEmojiPack(emojiPack: Note) = launchSigner { account.removeEmojiPack(emojiPack) } - fun addEmojiPack(emojiPack: Note) = runIOCatching { account.addEmojiPack(emojiPack) } + fun addEmojiPack(emojiPack: Note) = launchSigner { account.addEmojiPack(emojiPack) } fun addMediaToGallery( hex: String, @@ -725,40 +725,40 @@ class AccountViewModel( dim: DimensionTag?, hash: String?, mimeType: String?, - ) = runIOCatching { account.addToGallery(hex, url, relay, blurhash, dim, hash, mimeType) } + ) = launchSigner { account.addToGallery(hex, url, relay, blurhash, dim, hash, mimeType) } - fun removeFromMediaGallery(note: Note) = runIOCatching { account.removeFromGallery(note) } + fun removeFromMediaGallery(note: Note) = launchSigner { account.removeFromGallery(note) } fun hashtagFollows(user: User): Note = LocalCache.getOrCreateAddressableNote(HashtagListEvent.createAddress(user.pubkeyHex)) fun bookmarks(user: User): Note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user.pubkeyHex)) - fun addPrivateBookmark(note: Note) = runIOCatching { account.addBookmark(note, true) } + fun addPrivateBookmark(note: Note) = launchSigner { account.addBookmark(note, true) } - fun addPublicBookmark(note: Note) = runIOCatching { account.addBookmark(note, false) } + fun addPublicBookmark(note: Note) = launchSigner { account.addBookmark(note, false) } - fun removePrivateBookmark(note: Note) = runIOCatching { account.removeBookmark(note, true) } + fun removePrivateBookmark(note: Note) = launchSigner { account.removeBookmark(note, true) } - fun removePublicBookmark(note: Note) = runIOCatching { account.removeBookmark(note, false) } + fun removePublicBookmark(note: Note) = launchSigner { account.removeBookmark(note, false) } - fun broadcast(note: Note) = runIOCatching { account.broadcast(note) } + fun broadcast(note: Note) = launchSigner { account.broadcast(note) } - fun timestamp(note: Note) = runIOCatching { account.otsState.timestamp(note) } + fun timestamp(note: Note) = launchSigner { account.otsState.timestamp(note) } - fun delete(notes: List) = runIOCatching { account.delete(notes) } + fun delete(notes: List) = launchSigner { account.delete(notes) } - fun delete(note: Note) = runIOCatching { account.delete(note) } + fun delete(note: Note) = launchSigner { account.delete(note) } fun cachedDecrypt(note: Note): String? = account.cachedDecryptContent(note) fun decrypt( note: Note, onReady: (String) -> Unit, - ) = runIOCatching { + ) = launchSigner { account.decryptContent(note)?.let { onReady(it) } } - inline fun runIOCatching(crossinline action: suspend () -> Unit) { + inline fun launchSigner(crossinline action: suspend () -> Unit) { viewModelScope.launch(Dispatchers.IO) { try { action() @@ -796,35 +796,35 @@ class AccountViewModel( fun approveCommunityPost( post: Note, community: AddressableNote, - ) = runIOCatching { account.approveCommunityPost(post, community) } + ) = launchSigner { account.approveCommunityPost(post, community) } - fun follow(community: AddressableNote) = runIOCatching { account.follow(community) } + fun follow(community: AddressableNote) = launchSigner { account.follow(community) } - fun follow(channel: PublicChatChannel) = runIOCatching { account.follow(channel) } + fun follow(channel: PublicChatChannel) = launchSigner { account.follow(channel) } - fun follow(channel: EphemeralChatChannel) = runIOCatching { account.follow(channel) } + fun follow(channel: EphemeralChatChannel) = launchSigner { account.follow(channel) } - fun unfollow(community: AddressableNote) = runIOCatching { account.unfollow(community) } + fun unfollow(community: AddressableNote) = launchSigner { account.unfollow(community) } - fun unfollow(channel: PublicChatChannel) = runIOCatching { account.unfollow(channel) } + fun unfollow(channel: PublicChatChannel) = launchSigner { account.unfollow(channel) } - fun unfollow(channel: EphemeralChatChannel) = runIOCatching { account.unfollow(channel) } + fun unfollow(channel: EphemeralChatChannel) = launchSigner { account.unfollow(channel) } - fun follow(user: User) = runIOCatching { account.follow(user) } + fun follow(user: User) = launchSigner { account.follow(user) } - fun unfollow(user: User) = runIOCatching { account.unfollow(user) } + fun unfollow(user: User) = launchSigner { account.unfollow(user) } - fun followGeohash(tag: String) = runIOCatching { account.followGeohash(tag) } + fun followGeohash(tag: String) = launchSigner { account.followGeohash(tag) } - fun unfollowGeohash(tag: String) = runIOCatching { account.unfollowGeohash(tag) } + fun unfollowGeohash(tag: String) = launchSigner { account.unfollowGeohash(tag) } - fun followHashtag(tag: String) = runIOCatching { account.followHashtag(tag) } + fun followHashtag(tag: String) = launchSigner { account.followHashtag(tag) } - fun unfollowHashtag(tag: String) = runIOCatching { account.unfollowHashtag(tag) } + fun unfollowHashtag(tag: String) = launchSigner { account.unfollowHashtag(tag) } - fun showWord(word: String) = runIOCatching { account.showWord(word) } + fun showWord(word: String) = launchSigner { account.showWord(word) } - fun hideWord(word: String) = runIOCatching { account.hideWord(word) } + fun hideWord(word: String) = launchSigner { account.hideWord(word) } fun isLoggedUser(pubkeyHex: HexKey?): Boolean = account.signer.pubKey == pubkeyHex @@ -859,21 +859,21 @@ class AccountViewModel( fun filterSpamFromStrangers() = account.settings.syncedSettings.security.filterSpamFromStrangers - fun updateWarnReports(warnReports: Boolean) = runIOCatching { account.updateWarnReports(warnReports) } + fun updateWarnReports(warnReports: Boolean) = launchSigner { account.updateWarnReports(warnReports) } fun updateFilterSpam(filterSpam: Boolean) = - runIOCatching { + launchSigner { if (account.updateFilterSpam(filterSpam)) { LocalCache.antiSpam.active = filterSpamFromStrangers().value } } - fun updateShowSensitiveContent(show: Boolean?) = runIOCatching { account.updateShowSensitiveContent(show) } + fun updateShowSensitiveContent(show: Boolean?) = launchSigner { account.updateShowSensitiveContent(show) } fun changeReactionTypes( reactionSet: List, onDone: () -> Unit, - ) = runIOCatching { + ) = launchSigner { account.changeReactionTypes(reactionSet) onDone() } @@ -882,37 +882,37 @@ class AccountViewModel( amountSet: List, selectedZapType: LnZapEvent.ZapType, nip47Update: Nip47WalletConnect.Nip47URINorm?, - ) = runIOCatching { account.updateZapAmounts(amountSet, selectedZapType, nip47Update) } + ) = launchSigner { account.updateZapAmounts(amountSet, selectedZapType, nip47Update) } - fun toggleDontTranslateFrom(languageCode: String) = runIOCatching { account.toggleDontTranslateFrom(languageCode) } + fun toggleDontTranslateFrom(languageCode: String) = launchSigner { account.toggleDontTranslateFrom(languageCode) } - fun updateTranslateTo(languageCode: Locale) = runIOCatching { account.updateTranslateTo(languageCode) } + fun updateTranslateTo(languageCode: Locale) = launchSigner { account.updateTranslateTo(languageCode) } fun prefer( source: String, target: String, preference: String, - ) = runIOCatching { account.prefer(source, target, preference) } + ) = launchSigner { account.prefer(source, target, preference) } - fun show(user: User) = runIOCatching { account.showUser(user.pubkeyHex) } + fun show(user: User) = launchSigner { account.showUser(user.pubkeyHex) } - fun hide(user: User) = runIOCatching { account.hideUser(user.pubkeyHex) } + fun hide(user: User) = launchSigner { account.hideUser(user.pubkeyHex) } - fun hide(word: String) = runIOCatching { account.hideWord(word) } + fun hide(word: String) = launchSigner { account.hideWord(word) } - fun showUser(pubkeyHex: String) = runIOCatching { account.showUser(pubkeyHex) } + fun showUser(pubkeyHex: String) = launchSigner { account.showUser(pubkeyHex) } - fun createStatus(newStatus: String) = runIOCatching { account.createStatus(newStatus) } + fun createStatus(newStatus: String) = launchSigner { account.createStatus(newStatus) } fun updateStatus( address: Address, newStatus: String, - ) = runIOCatching { + ) = launchSigner { account.updateStatus(LocalCache.getOrCreateAddressableNote(address), newStatus) } fun deleteStatus(address: Address) = - runIOCatching { + launchSigner { account.deleteStatus(LocalCache.getOrCreateAddressableNote(address)) } @@ -977,53 +977,27 @@ class AccountViewModel( override suspend fun getOrCreateUser(hex: HexKey): User = LocalCache.getOrCreateUser(hex) - fun checkGetOrCreateUser( - key: HexKey, - onResult: (User?) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateUser(key)) } - } - fun getUserIfExists(hex: HexKey): User? = LocalCache.getUserIfExists(hex) fun checkGetOrCreateNote(key: HexKey): Note? = LocalCache.checkGetOrCreateNote(key) override suspend fun getOrCreateNote(hex: HexKey): Note = LocalCache.getOrCreateNote(hex) - fun checkGetOrCreateNote( - key: HexKey, - onResult: (Note?) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateNote(key)) } - } + fun noteFromEvent(event: Event): Note? { + var note = checkGetOrCreateNote(event.id) - fun checkGetOrCreateNote( - event: Event, - onResult: (Note?) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - var note = checkGetOrCreateNote(event.id) - - if (note == null) { - LocalCache.justConsume(event, null, false) - note = checkGetOrCreateNote(event.id) - } - - onResult(note) + if (note == null) { + LocalCache.justConsume(event, null, false) + note = checkGetOrCreateNote(event.id) } + + return note } fun getNoteIfExists(hex: HexKey): Note? = LocalCache.getNoteIfExists(hex) override suspend fun getOrCreateAddressableNote(address: Address): AddressableNote = LocalCache.getOrCreateAddressableNote(address) - fun getOrCreateAddressableNote( - key: Address, - onResult: (AddressableNote?) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { onResult(getOrCreateAddressableNote(key)) } - } - fun getAddressableNoteIfExists(key: String): AddressableNote? = LocalCache.getAddressableNoteIfExists(key) fun getAddressableNoteIfExists(key: Address): AddressableNote? = LocalCache.getAddressableNoteIfExists(key) @@ -1076,11 +1050,12 @@ class AccountViewModel( } } + fun sortUsersSync(hexList: List): List = hexList.sortedByDescending { account.isKnown(it) } + fun loadUsersSync(hexList: List): List = hexList .mapNotNull { hex -> checkGetOrCreateUser(hex) } - .sortedBy { account.isFollowing(it) } - .reversed() + .sortedByDescending { account.isKnown(it) } suspend fun checkVideoIsOnline(videoUrl: String): Boolean = withContext(Dispatchers.IO) { @@ -1177,10 +1152,9 @@ class AccountViewModel( context: Context, ) { if (isWriteable()) { - val hint = note.toEventHint() - if (hint == null) return + val hint = note.toEventHint() ?: return - runIOCatching { + launchSigner { val uploader = UploadOrchestrator() val result = uploader.upload( @@ -1273,7 +1247,7 @@ class AccountViewModel( if (isWriteable()) { val boosts = baseNote.boostedBy(userProfile()) if (boosts.isNotEmpty()) { - runIOCatching { + launchSigner { account.delete(boosts) } } else { @@ -1335,9 +1309,7 @@ class AccountViewModel( if (existingNoteEvent != null) { unwrapIfNeeded(existingNoteEvent) } else { - val newEvent = event.unwrapOrNull(account.signer) - - if (newEvent == null) return null + val newEvent = event.unwrapOrNull(account.signer) ?: return null // clear the encrypted payload to save memory LocalCache.getOrCreateNote(event.id).event = event.copyNoContent() @@ -1403,7 +1375,7 @@ class AccountViewModel( fun unwrapIfNeeded( note: Note?, onReady: (Note) -> Unit = {}, - ) = runIOCatching { + ) = launchSigner { val noteEvent = note?.event if (noteEvent != null) { val resultingNote = unwrapIfNeeded(noteEvent) @@ -1435,7 +1407,7 @@ class AccountViewModel( dvmPublicKey: User, onReady: (event: Note) -> Unit, ) { - runIOCatching { + launchSigner { account.requestDVMContentDiscovery(dvmPublicKey) { onReady(LocalCache.getOrCreateNote(it.id)) } @@ -1469,7 +1441,7 @@ class AccountViewModel( zappedNote: Note?, onSent: () -> Unit = {}, onResponse: (Response?) -> Unit, - ) = runIOCatching { + ) = launchSigner { account.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse) onSent() } @@ -1480,7 +1452,7 @@ class AccountViewModel( root: InteractiveStoryBaseEvent, readingScene: InteractiveStoryBaseEvent, ) { - runIOCatching { + launchSigner { val sceneNoteRelayHint = LocalCache.getOrCreateAddressableNote(readingScene.address()).relayHintUrl() val readingState = getInteractiveStoryReadingState(root.addressTag()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt index ee8818c0f..34c7600c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt @@ -117,7 +117,7 @@ fun RenderChannelDataPreview() { tags = EmptyTagList, bgColor = remember { mutableStateOf(Color.Transparent) }, accountViewModel = mockAccountViewModel(), - nav = EmptyNav, + nav = EmptyNav(), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index 27358e239..31288da13 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -72,19 +72,17 @@ fun ChatroomView( if (replyToNote != null) { LaunchedEffect(key1 = replyToNote, newPostModel, accountViewModel) { - accountViewModel.checkGetOrCreateNote(replyToNote) { - if (it != null) { - newPostModel.reply(it) - } + val replyNote = accountViewModel.checkGetOrCreateNote(replyToNote) + if (replyNote != null) { + newPostModel.reply(replyNote) } } } if (editFromDraft != null) { LaunchedEffect(editFromDraft, newPostModel, accountViewModel) { - accountViewModel.checkGetOrCreateNote(editFromDraft) { - if (it != null) { - newPostModel.editFromDraft(it) - } + val draftNote = accountViewModel.checkGetOrCreateNote(editFromDraft) + if (draftNote != null) { + newPostModel.editFromDraft(draftNote) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt index e8fd51ae0..d9c4564f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt @@ -98,7 +98,7 @@ fun NewChatroomSubjectDialog( PostButton( onPost = { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { val template = ChatMessageEvent.build( message.value, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index ed36b2e20..c2ce04d5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -114,7 +114,7 @@ class ChatNewMessageViewModel : draftTag.versions.collectLatest { // don't save the first if (it > 0) { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { sendDraftSync() } } @@ -325,9 +325,7 @@ class ChatNewMessageViewModel : } if (replyId != null) { - accountViewModel.checkGetOrCreateNote(replyId) { - replyTo.value = it - } + replyTo.value = accountViewModel.checkGetOrCreateNote(replyId) } } else if (draftEvent is PrivateDmEvent) { val recipientNPub = draftEvent.verifiedRecipientPubKey()?.let { Hex.decode(it).toNpub() } @@ -335,9 +333,7 @@ class ChatNewMessageViewModel : val replyId = draftEvent.replyTo() if (replyId != null) { - accountViewModel.checkGetOrCreateNote(replyId) { - replyTo.value = it - } + replyTo.value = accountViewModel.checkGetOrCreateNote(replyId) } } @@ -393,7 +389,7 @@ class ChatNewMessageViewModel : ) { val uploadState = uploadState ?: return - accountViewModel.runIOCatching { + accountViewModel.launchSigner { if (nip17) { ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) { uploadsWaitingToBeSent += it @@ -418,7 +414,7 @@ class ChatNewMessageViewModel : val room = room ?: return val uploadState = uploadState ?: return - accountViewModel.runIOCatching { + accountViewModel.launchSigner { if (nip17) { ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) { ChatFileSender(room, account).sendNIP17(it) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index 21f0f7f6c..ab72a1d18 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -169,7 +169,7 @@ fun NewGroupDMScreen( WatchAndLoadMyEmojiList(accountViewModel) BackHandler { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() } @@ -184,7 +184,7 @@ fun NewGroupDMScreen( onCancel = { // uses the accountViewModel scope to avoid cancelling this // function when the postViewModel is released - accountViewModel.runIOCatching { + accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() } @@ -193,7 +193,7 @@ fun NewGroupDMScreen( onPost = { // uses the accountViewModel scope to avoid cancelling this // function when the postViewModel is released - accountViewModel.runIOCatching { + accountViewModel.launchSigner { postViewModel.sendPostSync() postViewModel.room?.let { nav.nav(routeToMessage(it, null, null, null, null, accountViewModel)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index f959c987a..34f637ef7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -72,7 +72,7 @@ fun PrivateMessageEditFieldRowPreview() { channelScreenModel = channelScreenModel, accountViewModel = accountViewModel, onSendNewMessage = {}, - nav = EmptyNav, + nav = EmptyNav(), ) } } @@ -85,7 +85,7 @@ fun PrivateMessageEditFieldRow( nav: INav, ) { BackHandler { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { channelScreenModel.sendDraftSync() channelScreenModel.cancel() } @@ -170,7 +170,7 @@ fun EditField( isActive = channelScreenModel.canPost(), modifier = EditFieldTrailingIconModifier, ) { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { channelScreenModel.sendPostSync() onSendNewMessage() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt index b1d583a9c..a28b18a3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt @@ -81,7 +81,7 @@ private fun DialogContentPreview() { ChannelMetadataScaffold( postViewModel = postViewModel, accountViewModel = accountViewModel, - nav = EmptyNav, + nav = EmptyNav(), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt index 2481cb37c..8407ceb71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -91,7 +92,17 @@ fun ChannelMetadataScreen( nav: INav, ) { val postViewModel: ChannelMetadataViewModel = viewModel() - postViewModel.load(accountViewModel.account, channel) + postViewModel.init(accountViewModel) + + if (channel != null) { + LaunchedEffect(postViewModel) { + postViewModel.load(channel) + } + } else { + LaunchedEffect(postViewModel) { + postViewModel.new() + } + } ChannelMetadataScaffold( postViewModel = postViewModel, @@ -105,13 +116,13 @@ fun ChannelMetadataScreen( private fun DialogContentPreview() { val accountViewModel = mockAccountViewModel() val postViewModel: ChannelMetadataViewModel = viewModel() - postViewModel.load(accountViewModel.account, null) + postViewModel.init(accountViewModel) ThemeComparisonColumn { ChannelMetadataScaffold( postViewModel = postViewModel, accountViewModel = accountViewModel, - nav = EmptyNav, + nav = EmptyNav(), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt index 4a827f595..7c9850706 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata import android.content.Context +import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -39,6 +40,7 @@ import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.stringRes @@ -54,8 +56,11 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlin.coroutines.cancellation.CancellationException +@Stable class ChannelMetadataViewModel : ViewModel() { - private var account: Account? = null + private lateinit var accountViewModel: AccountViewModel + private lateinit var account: Account + private var originalChannel: PublicChatChannel? = null val channelName = mutableStateOf(TextFieldValue()) @@ -71,24 +76,28 @@ class ChannelMetadataViewModel : ViewModel() { channelName.value.text.isNotBlank() } - fun load( - account: Account, - channel: PublicChatChannel?, - ) { - this.account = account - if (channel != null) { - originalChannel = channel - channelName.value = TextFieldValue(channel.info.name ?: "") - channelPicture.value = TextFieldValue(channel.info.picture ?: "") - channelDescription.value = TextFieldValue(channel.info.about ?: "") + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account + } - val relays = - channel.info.relays - ?.map { relaySetupInfoBuilder(it) } - ?.distinctBy { it.relay } + fun new() { + originalChannel = null + clear() + } - _channelRelays.update { relays ?: emptyList() } - } + fun load(channel: PublicChatChannel) { + originalChannel = channel + channelName.value = TextFieldValue(channel.info.name ?: "") + channelPicture.value = TextFieldValue(channel.info.picture ?: "") + channelDescription.value = TextFieldValue(channel.info.about ?: "") + + val relays = + channel.info.relays + ?.map { relaySetupInfoBuilder(it) } + ?.distinctBy { it.relay } + + _channelRelays.update { relays ?: emptyList() } } fun isNewChannel() = originalChannel == null && _channelRelays.value.isNotEmpty() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 433a9aaf2..4d3a5b6b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -107,7 +107,7 @@ open class ChannelNewMessageViewModel : draftTag.versions.collectLatest { // don't save the first if (it > 0) { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { sendDraftSync() } } @@ -242,16 +242,12 @@ open class ChannelNewMessageViewModel : if (draftEvent as? ChannelMessageEvent != null) { val replyId = draftEvent.reply()?.eventId if (replyId != null) { - accountViewModel.checkGetOrCreateNote(replyId) { - replyTo.value = it - } + replyTo.value = accountViewModel.checkGetOrCreateNote(replyId) } } else if (draftEvent as? LiveActivitiesChatMessageEvent != null) { val replyId = draftEvent.reply()?.eventId if (replyId != null) { - accountViewModel.checkGetOrCreateNote(replyId) { - replyTo.value = it - } + replyTo.value = accountViewModel.checkGetOrCreateNote(replyId) } } @@ -263,7 +259,7 @@ open class ChannelNewMessageViewModel : } fun sendPost(onDone: suspend () -> Unit) { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { sendPostSync() onDone() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index 0b6085faa..6cc1c9370 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -62,7 +62,7 @@ fun EditFieldRow( nav: INav, ) { BackHandler { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { channelScreenModel.sendDraftSync() channelScreenModel.cancel() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index c323c467c..be8594ff1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -327,11 +327,7 @@ fun LoadUser( if (user == null) { LaunchedEffect(key1 = baseUserHex) { - accountViewModel.checkGetOrCreateUser(baseUserHex) { newUser -> - if (user != newUser) { - user = newUser - } - } + user = accountViewModel.checkGetOrCreateUser(baseUserHex) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt index 0718e0265..a3884ac28 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt @@ -63,7 +63,7 @@ private fun TopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) }, + onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/FollowSetCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/FollowSetCard.kt index dda0079c9..2314bf79f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/FollowSetCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/FollowSetCard.kt @@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -39,13 +38,13 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.Gallery +import com.vitorpamplona.amethyst.ui.note.GalleryUnloaded import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay @@ -58,13 +57,13 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.FollowSetImageModifier import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp -import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size25dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @Immutable @@ -72,7 +71,7 @@ data class FollowSetCard( val name: String, val media: String?, val description: String?, - val users: ImmutableList, + val users: ImmutableList, ) @Composable @@ -90,7 +89,7 @@ fun RenderFollowSetThumb( description = noteEvent?.description(), users = accountViewModel - .loadUsersSync( + .sortUsersSync( noteEvent?.followIds() ?: emptyList(), ).toImmutableList(), ) @@ -108,30 +107,49 @@ fun RenderFollowSetThumb( @Composable fun RenderFollowSetThumbPreview() { val accountViewModel = mockAccountViewModel() - val nav = EmptyNav + val nav = EmptyNav() - ThemeComparisonColumn( - toPreview = { - RenderFollowSetThumb( - card = - FollowSetCard( - "Orange Pill Perú", - "https://i.postimg.cc/GtDgGY5v/5062563795762785335.jpg", - "Desc", - persistentListOf( - accountViewModel.userProfile(), - accountViewModel.userProfile(), - accountViewModel.userProfile(), - accountViewModel.userProfile(), - accountViewModel.userProfile(), - ), - ), - baseNote = Note(""), - accountViewModel = accountViewModel, - nav = nav, - ) - }, - ) + val followCard = + FollowListEvent( + id = "eca31634fce7c9068b56fa8db9f387da70bdcceb3986a77ca1a9844f3128eb5f", + pubKey = "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da", + createdAt = 1761736286, + tags = + arrayOf( + arrayOf("title", "Retro Computer Fans"), + arrayOf("d", "xmbspe8rddsq"), + arrayOf("image", "https://blog.johnnovak.net/2022/04/15/achieving-period-correct-graphics-in-personal-computer-emulators-part-1-the-amiga/img/dream-setup.jpg"), + arrayOf("p", "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da"), + arrayOf("p", "9a9a4aa0e43e57873380ab22e8a3df12f3c4cf5bb3a804c6e3fed0069a6e2740"), + arrayOf("p", "4f5dd82517b11088ce00f23d99f06fe8f3e2e45ecf47bc9c2f90f34d5c6f7382"), + arrayOf("p", "ac92102a2ecb873c488e0125354ef5a97075a16198668c360eda050007ed42cd"), + arrayOf("p", "47f54409a4620eb35208a3bc1b53555bf3d0656b246bf0471a93208e20672f6f"), + arrayOf("p", "2624911545afb7a2b440cf10f5c69308afa33aae26fca664d8c94623dc0f1baf"), + arrayOf("p", "6641f26f5c59f7010dbe3e42e4593398e27c087497cb7d20e0e7633a17e48a94"), + arrayOf("description", "Retro computer fans and enthusiasts "), + ), + content = "", + sig = "3aa388edafad151e81cb0228fe04e115dbbcaa851c666bfe3c8740b6cd99575f0fc3ba2d47acda86f7626564a05e9dbc05ef452a7bd0ac00f828dbad0e1bae6c", + ) + + LocalCache.justConsume(followCard, null, false) + + val card = + FollowSetCard( + name = followCard.title()?.ifBlank { null } ?: followCard.dTag(), + media = followCard.image()?.ifBlank { null }, + description = followCard.description(), + users = followCard.followIds().toImmutableList(), + ) + + ThemeComparisonColumn { + RenderFollowSetThumb( + card = card, + baseNote = LocalCache.getOrCreateNote(followCard.id), + accountViewModel = accountViewModel, + nav = nav, + ) + } } @Composable @@ -164,7 +182,7 @@ fun RenderFollowSetThumb( ) } ?: run { DefaultImageHeader(baseNote, accountViewModel, FollowSetImageModifier) } - Gallery(card.users, Modifier.padding(Size10dp), accountViewModel, nav) + GalleryUnloaded(card.users, StdPadding, accountViewModel, nav) } Spacer(modifier = DoubleVertSpacer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index 4f5a33799..4128694c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -45,7 +45,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note @@ -81,11 +80,9 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size5dp -import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) @@ -139,7 +136,7 @@ fun NewProductScreen( WatchAndLoadMyEmojiList(accountViewModel) BackHandler { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() } @@ -154,23 +151,16 @@ fun NewProductScreen( onCancel = { // uses the accountViewModel scope to avoid cancelling this // function when the postViewModel is released - accountViewModel.runIOCatching { + accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() } nav.popBack() }, onPost = { - try { - accountViewModel.viewModelScope.launch(Dispatchers.IO) { - postViewModel.sendPostSync() - nav.popBack() - } - } catch (e: SignerExceptions.ReadOnlyException) { - accountViewModel.toastManager.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_sign_events, - ) + accountViewModel.launchSigner { + postViewModel.sendPostSync() + nav.popBack() } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index 6b7d21dd1..0cc63b69e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -104,15 +104,15 @@ open class NewProductViewModel : IZapRaiser { val draftTag = DraftTagState() - var accountViewModel: AccountViewModel? = null - var account: Account? = null + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account init { viewModelScope.launch(Dispatchers.IO) { draftTag.versions.collectLatest { // don't save the first if (it > 0) { - accountViewModel?.runIOCatching { + accountViewModel.launchSigner { sendDraftSync() } } @@ -189,8 +189,6 @@ open class NewProductViewModel : } fun editFromDraft(draft: Note) { - val accountViewModel = accountViewModel ?: return - val noteEvent = draft.event val noteAuthor = draft.author diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/FollowPackFeedScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/FollowPackFeedScreen.kt new file mode 100644 index 000000000..ecefa80a5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/FollowPackFeedScreen.kt @@ -0,0 +1,329 @@ +/** + * 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.followPacks.feed + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar +import com.vitorpamplona.amethyst.ui.navigation.topbars.TitleIconModifier +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarSize +import com.vitorpamplona.amethyst.ui.note.LikeReaction +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.note.ReplyReaction +import com.vitorpamplona.amethyst.ui.note.ZapReaction +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.screen.UserFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.dal.FollowPackFeedConversationsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.dal.FollowPackFeedNewThreadFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.dal.FollowPackMembersUserFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.HalfHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.Size18Modifier +import com.vitorpamplona.amethyst.ui.theme.SpacedBy2dp +import com.vitorpamplona.amethyst.ui.theme.TabRowHeight +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FollowPackFeedScreen( + address: Address, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadAddressableNote(address, accountViewModel) { + it?.let { + PrepareViewModelsFollowPackScreen( + note = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@SuppressLint("StateFlowValueCalledInComposition") +@Composable +fun PrepareViewModelsFollowPackScreen( + note: AddressableNote, + accountViewModel: AccountViewModel, + nav: INav, +) { + val conversationsFeedViewModel: FollowPackFeedConversationsFeedViewModel = + viewModel( + key = note.idHex + "ConversationsFeedViewModel", + factory = + FollowPackFeedConversationsFeedViewModel.Factory( + note, + accountViewModel.account, + ), + ) + + val newThreadFeedViewModel: FollowPackFeedNewThreadFeedViewModel = + viewModel( + key = note.idHex + "NewThreadFeedViewModel", + factory = + FollowPackFeedNewThreadFeedViewModel.Factory( + note, + accountViewModel.account, + ), + ) + + val membersFeedViewModel: FollowPackMembersUserFeedViewModel = + viewModel( + key = note.idHex + "MembersFeedViewModel", + factory = + FollowPackMembersUserFeedViewModel.Factory( + note, + accountViewModel.account, + ), + ) + + FollowPackFeedScreen(note, newThreadFeedViewModel, conversationsFeedViewModel, membersFeedViewModel, accountViewModel, nav) +} + +@Composable +fun FollowPackFeedScreen( + note: AddressableNote, + newThreadFeedViewModel: FollowPackFeedNewThreadFeedViewModel, + conversationsFeedViewModel: FollowPackFeedConversationsFeedViewModel, + membersFeedViewModel: FollowPackMembersUserFeedViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(newThreadFeedViewModel) + WatchLifecycleAndUpdateModel(conversationsFeedViewModel) + WatchLifecycleAndUpdateModel(membersFeedViewModel) + + FollowPackFeedFilterAssemblerSubscription(note, accountViewModel) + + val pagerState = rememberForeverPagerState(note.idHex + "FollowPackScreenPagerState") { 3 } + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + FollowPackFeedTopBar( + note, + pagerState, + accountViewModel, + nav, + ) + }, + accountViewModel = accountViewModel, + ) { + HorizontalPager( + contentPadding = it, + state = pagerState, + ) { page -> + when (page) { + 0 -> + RefresheableFeedView( + newThreadFeedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + 1 -> + RefresheableFeedView( + conversationsFeedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + 2 -> + UserFeedView( + membersFeedViewModel, + accountViewModel, + nav, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FollowPackFeedTopBar( + note: AddressableNote, + pagerState: PagerState, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column { + val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val modifier = Modifier.fillMaxWidth().height(TopBarSize + statusBarHeight) + Box( + modifier = modifier, // Adjust height as needed for your banner + ) { + DisplayBanner(note, Modifier.fillMaxSize(), accountViewModel) + + ShorterTopAppBar( + title = { + FollowPackHeader(note, accountViewModel, nav) + }, + navigationIcon = { + Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) { + IconButton( + onClick = nav::popBack, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + } + }, + actions = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = SpacedBy2dp) { + ReplyReaction( + baseNote = note, + grayTint = MaterialTheme.colorScheme.onBackground, + accountViewModel = accountViewModel, + iconSizeModifier = Size18Modifier, + ) { + nav.nav { + Route.Note(note.idHex) + } + } + Spacer(modifier = HalfHorzSpacer) + LikeReaction( + baseNote = note, + grayTint = MaterialTheme.colorScheme.onBackground, + accountViewModel = accountViewModel, + nav, + ) + Spacer(modifier = HalfHorzSpacer) + ZapReaction( + baseNote = note, + grayTint = MaterialTheme.colorScheme.onBackground, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(modifier = HalfHorzSpacer) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.6f), // Make TopAppBar background transparent + ), + ) + } + + TabRow( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onBackground, + modifier = TabRowHeight, + selectedTabIndex = pagerState.currentPage, + ) { + val coroutineScope = rememberCoroutineScope() + Tab( + selected = pagerState.currentPage == 0, + text = { Text(text = stringRes(R.string.new_threads)) }, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, + ) + Tab( + selected = pagerState.currentPage == 1, + text = { Text(text = stringRes(R.string.conversations)) }, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, + ) + Tab( + selected = pagerState.currentPage == 2, + text = { Text(text = stringRes(R.string.members)) }, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } }, + ) + } + } +} + +@Composable +private fun DisplayBanner( + baseNote: AddressableNote, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, +) { + val noteEvent by observeNoteEvent(baseNote, accountViewModel) + + noteEvent?.image()?.let { + AsyncImage( + model = it, + contentDescription = stringRes(R.string.preview_card_image_for, it), + contentScale = ContentScale.Crop, + modifier = Modifier, + ) + } +} + +@Composable +fun FollowPackHeader( + baseNote: AddressableNote, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent by observeNoteEvent(baseNote, accountViewModel) + + Text( + text = noteEvent?.title() ?: baseNote.dTag(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedFilter.kt new file mode 100644 index 000000000..8d5e34deb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedFilter.kt @@ -0,0 +1,118 @@ +/** + * 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.followPacks.feed.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent + +class FollowPackFeedConversationsFeedFilter( + val followPackNote: AddressableNote, + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultHomeFollowList.value + + override fun showHiddenKey(): Boolean = + account.liveHomeFollowLists.value is MutedAuthorsByOutboxTopNavFilter || + account.liveHomeFollowLists.value is MutedAuthorsByProxyTopNavFilter + + override fun feed(): List { + val filterParams = buildFilterParams(account) + + return sort( + LocalCache.notes.filterIntoSet { _, it -> + acceptableEvent(it, filterParams) + }, + ) + } + + val followPackEvent = followPackNote.event as? FollowListEvent + val follows = followPackEvent?.followIdSet() ?: emptySet() + + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + followLists = + if (account.proxyRelayList.flow.value + .isEmpty() + ) { + AllUserFollowsByOutboxTopNavFilter( + authors = follows, + defaultRelays = account.defaultGlobalRelays.flow, + blockedRelays = account.blockedRelayList.flow, + ) + } else { + AllUserFollowsByProxyTopNavFilter( + authors = follows, + proxyRelays = account.proxyRelayList.flow.value, + ) + }, + hiddenUsers = account.hiddenUsers.flow.value, + ) + + private fun innerApplyFilter(collection: Collection): Set { + val filterParams = buildFilterParams(account) + + return collection.filterTo(HashSet()) { + acceptableEvent(it, filterParams) + } + } + + fun acceptableEvent( + event: Event?, + filterParams: FilterByListParams, + ): Boolean = + ( + event is TextNoteEvent || + event is PollNoteEvent || + event is ChannelMessageEvent || + event is CommentEvent || + event is VoiceReplyEvent || + event is PublicMessageEvent || + event is LiveActivitiesChatMessageEvent + ) && + filterParams.match(event) + + fun acceptableEvent( + note: Note, + filterParams: FilterByListParams, + ): Boolean = acceptableEvent(note.event, filterParams) && !note.isNewThread() + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedViewModel.kt new file mode 100644 index 000000000..3851b9dfb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedViewModel.kt @@ -0,0 +1,40 @@ +/** + * 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.followPacks.feed.dal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel + +class FollowPackFeedConversationsFeedViewModel( + val note: AddressableNote, + val account: Account, +) : FeedViewModel(FollowPackFeedConversationsFeedFilter(note, account)) { + class Factory( + val note: AddressableNote, + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = FollowPackFeedConversationsFeedViewModel(note, account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt new file mode 100644 index 000000000..ac8b756a2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt @@ -0,0 +1,152 @@ +/** + * 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.followPacks.feed.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.filterIntoSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByProxyTopNavFilter +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent + +class FollowPackFeedNewThreadFeedFilter( + val followPackNote: AddressableNote, + val account: Account, +) : AdditiveFeedFilter() { + companion object Companion { + val ADDRESSABLE_KINDS = + listOf( + AudioTrackEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + WikiNoteEvent.KIND, + ClassifiedsEvent.KIND, + LongTextNoteEvent.KIND, + ) + } + + val followPackEvent = followPackNote.event as? FollowListEvent + val follows = followPackEvent?.followIdSet() ?: emptySet() + + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followPackNote.idHex + + override fun showHiddenKey(): Boolean = false + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + followLists = + if (account.proxyRelayList.flow.value + .isEmpty() + ) { + AllUserFollowsByOutboxTopNavFilter( + authors = follows, + defaultRelays = account.defaultGlobalRelays.flow, + blockedRelays = account.blockedRelayList.flow, + ) + } else { + AllUserFollowsByProxyTopNavFilter( + authors = follows, + proxyRelays = account.proxyRelayList.flow.value, + ) + }, + hiddenUsers = account.hiddenUsers.flow.value, + ) + + override fun feed(): List { + val filterParams = buildFilterParams(account) + + val notes = + LocalCache.notes.filterIntoSet { _, note -> + // Avoids processing addressables twice. + (note.event?.kind ?: 99999) < 10000 && acceptableEvent(note, filterParams) + } + + val longFormNotes = + LocalCache.addressables.filterIntoSet( + kinds = ADDRESSABLE_KINDS, + ) { _, note -> + acceptableEvent(note, filterParams) + } + + return sort(notes + longFormNotes) + } + + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + private fun innerApplyFilter(collection: Collection): Set { + val filterParams = buildFilterParams(account) + + return collection.filterTo(HashSet()) { + acceptableEvent(it, filterParams) + } + } + + private fun acceptableEvent( + it: Note, + filterParams: FilterByListParams, + ): Boolean { + val noteEvent = it.event + return ( + noteEvent is TextNoteEvent || + noteEvent is ClassifiedsEvent || + noteEvent is RepostEvent || + noteEvent is GenericRepostEvent || + (noteEvent is LongTextNoteEvent && noteEvent.content.isNotEmpty()) || + (noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) || + noteEvent is PollNoteEvent || + noteEvent is HighlightEvent || + noteEvent is InteractiveStoryPrologueEvent || + noteEvent is CommentEvent || + noteEvent is AudioTrackEvent || + noteEvent is VoiceEvent || + noteEvent is AudioHeaderEvent + ) && + filterParams.match(noteEvent, it.relays) && + it.isNewThread() + } + + override fun sort(items: Set): List = + items + .distinctBy { + if (it.event is RepostEvent || it.event is GenericRepostEvent) { + it.replyTo?.lastOrNull()?.idHex ?: it.idHex // only the most recent repost per feed. + } else { + it.idHex + } + }.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedViewModel.kt new file mode 100644 index 000000000..74b9159a6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedViewModel.kt @@ -0,0 +1,40 @@ +/** + * 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.followPacks.feed.dal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel + +class FollowPackFeedNewThreadFeedViewModel( + val note: AddressableNote, + val account: Account, +) : FeedViewModel(FollowPackFeedNewThreadFeedFilter(note, account)) { + class Factory( + val note: AddressableNote, + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = FollowPackFeedNewThreadFeedViewModel(note, account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackMembersFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackMembersFeedFilter.kt new file mode 100644 index 000000000..2329eca2c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackMembersFeedFilter.kt @@ -0,0 +1,54 @@ +/** + * 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.followPacks.feed.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache.checkGetOrCreateUser +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.FeedFilter +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent + +class FollowPackMembersFeedFilter( + val followPackNote: AddressableNote, + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followPackNote.idHex + + val cache: MutableMap> = mutableMapOf() + + override fun feed(): List { + val followPackEvent = followPackNote.event as? FollowListEvent ?: return emptyList() + + val previousList = cache[followPackEvent] + if (previousList != null) return previousList + + val follows = + followPackEvent + .followIdSet() + .mapNotNull { hex -> checkGetOrCreateUser(hex) } + .filter { !account.isHidden(it) } + .sortedByDescending { account.isKnown(it) } + + cache[followPackEvent] = follows + return follows + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackMembersUserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackMembersUserFeedViewModel.kt new file mode 100644 index 000000000..2eff19754 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackMembersUserFeedViewModel.kt @@ -0,0 +1,40 @@ +/** + * 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.followPacks.feed.dal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel + +class FollowPackMembersUserFeedViewModel( + val followPackNote: AddressableNote, + val account: Account, +) : UserFeedViewModel(FollowPackMembersFeedFilter(followPackNote, account)) { + class Factory( + val followPackNote: AddressableNote, + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = FollowPackMembersUserFeedViewModel(followPackNote, account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssembler.kt new file mode 100644 index 000000000..ef3f1a91a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssembler.kt @@ -0,0 +1,49 @@ +/** + * 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.followPacks.feed.datasource + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient + +// This allows multiple screen to be listening to tags, even the same tag +class FollowPackFeedQueryState( + var followPack: AddressableNote, + var account: Account, +) + +@Stable +class FollowPackFeedFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + FollowPackFeedFilterSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssemblerSubscription.kt new file mode 100644 index 000000000..c30204f15 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssemblerSubscription.kt @@ -0,0 +1,42 @@ +/** + * 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.followPacks.feed.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun FollowPackFeedFilterAssemblerSubscription( + pack: AddressableNote, + accountViewModel: AccountViewModel, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(pack) { + FollowPackFeedQueryState(pack, accountViewModel.account) + } + + KeyDataSourceSubscription(state, accountViewModel.dataSources().followPacks) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterSubAssembler.kt new file mode 100644 index 000000000..80681a5f2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterSubAssembler.kt @@ -0,0 +1,68 @@ +/** + * 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.followPacks.feed.datasource + +import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByProxyTopNavFilter +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.filterHomePostsByAuthors +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent + +class FollowPackFeedFilterSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : SingleSubEoseManager(client, allKeys) { + override fun updateFilter( + keys: List, + since: SincePerRelayMap?, + ): List { + if (keys.isEmpty()) return emptyList() + return keys.flatMap { + val followPack = it.followPack.event + if (followPack is FollowListEvent) { + val filter = + if (it.account.proxyRelayList.flow.value + .isEmpty() + ) { + AllUserFollowsByOutboxTopNavFilter( + authors = followPack.followIdSet(), + defaultRelays = it.account.defaultGlobalRelays.flow, + blockedRelays = it.account.blockedRelayList.flow, + ).startValue(it.account.cache) + } else { + AllUserFollowsByProxyTopNavFilter( + authors = followPack.followIdSet(), + proxyRelays = it.account.proxyRelayList.flow.value, + ).startValue(it.account.cache) + } + + filterHomePostsByAuthors(filter, since, null, null) + } else { + emptyList() + } + } + } + + override fun distinct(key: FollowPackFeedQueryState) = key.followPack.idHex +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AddOutboxRelayCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AddOutboxRelayCard.kt index 69682a082..679b5a2e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AddOutboxRelayCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AddOutboxRelayCard.kt @@ -57,7 +57,7 @@ fun AddOutboxRelayCardPreview() { ThemeComparisonColumn { AddInboxRelayCard( accountViewModel = mockAccountViewModel(), - nav = EmptyNav, + nav = EmptyNav(), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt index 5bc35e195..745f30b02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt @@ -68,7 +68,7 @@ private fun TopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) }, + onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index bf5ebb91f..581d6825d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -167,7 +167,7 @@ private fun NewPostScreenInner( WatchAndLoadMyEmojiList(accountViewModel) BackHandler { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() } @@ -181,7 +181,7 @@ private fun NewPostScreenInner( onPost = { // uses the accountViewModel scope to avoid cancelling this // function when the postViewModel is released - accountViewModel.runIOCatching { + accountViewModel.launchSigner { postViewModel.sendPostSync() nav.popBack() } @@ -189,7 +189,7 @@ private fun NewPostScreenInner( onCancel = { // uses the accountViewModel scope to avoid cancelling this // function when the postViewModel is released - accountViewModel.runIOCatching { + accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 28bafef82..bb07d2c19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -146,7 +146,7 @@ open class ShortNotePostViewModel : draftTag.versions.collectLatest { // don't save the first if (it > 0) { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { sendDraftSync() } } @@ -483,7 +483,7 @@ open class ShortNotePostViewModel : cancel() accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) - accountViewModel.viewModelScope.launch(Dispatchers.IO) { + accountViewModel.launchSigner { accountViewModel.account.deleteDraftIgnoreErrors(version) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/ShowUserSuggestions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/ShowUserSuggestions.kt new file mode 100644 index 000000000..1e4f1c93a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/ShowUserSuggestions.kt @@ -0,0 +1,281 @@ +/** + * 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.lists.display + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults.cardElevation +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription +import com.vitorpamplona.amethyst.ui.note.AboutDisplay +import com.vitorpamplona.amethyst.ui.note.ClearTextIcon +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.AnimateOnNewSearch +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +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.HalfVertSpacer +import com.vitorpamplona.amethyst.ui.theme.LightRedColor +import com.vitorpamplona.amethyst.ui.theme.PopupUpEffect +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.SmallBorder +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +@Composable +fun RenderAddUserFieldAndSuggestions( + userSuggestions: UserSuggestionState, + hasUserFlow: (User) -> Flow, + addUserToSet: (User) -> Unit, + removeUserFromSet: (User) -> Unit, + accountViewModel: AccountViewModel, +) { + UserSearchDataSourceSubscription(userSuggestions, accountViewModel) + + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { + userSuggestions.invalidateData() + } + } + launch(Dispatchers.IO) { + LocalCache.live.deletedEventBundles.collect { + userSuggestions.invalidateData() + } + } + } + + Spacer(HalfVertSpacer) + + var userName by remember(userSuggestions) { mutableStateOf(TextFieldValue(userSuggestions.currentWord.value)) } + val focusManager = LocalFocusManager.current + + OutlinedTextField( + label = { Text(text = stringRes(R.string.search_and_add_a_user)) }, + modifier = Modifier.padding(horizontal = Size10dp).fillMaxWidth(), + value = userName, + onValueChange = { + userName = it + userSuggestions.processCurrentWord(it.text) + }, + singleLine = true, + trailingIcon = { + IconButton( + onClick = { + userName = TextFieldValue("") + userSuggestions.processCurrentWord("") + focusManager.clearFocus() + }, + ) { + ClearTextIcon() + } + }, + ) + + ShowUserSuggestions( + userSuggestions = userSuggestions, + hasUserFlow = hasUserFlow, + onSelect = { user -> + addUserToSet(user) + userName = + userName.copy( + selection = TextRange(0, userName.text.length), + ) + }, + onDelete = { user -> + removeUserFromSet(user) + userName = + userName.copy( + selection = TextRange(0, userName.text.length), + ) + }, + accountViewModel = accountViewModel, + ) +} + +@Composable +fun ShowUserSuggestions( + userSuggestions: UserSuggestionState, + hasUserFlow: (User) -> Flow, + onSelect: (User) -> Unit, + onDelete: (User) -> Unit, + accountViewModel: AccountViewModel, +) { + val listState = rememberLazyListState() + + AnimateOnNewSearch(userSuggestions, listState) + + val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList()) + + if (suggestions.isNotEmpty()) { + Card( + modifier = Modifier.padding(start = 11.dp, end = 11.dp), + elevation = cardElevation(5.dp), + shape = PopupUpEffect, + ) { + LazyColumn( + contentPadding = PaddingValues(top = 10.dp), + modifier = + Modifier + .heightIn(0.dp, 200.dp), + state = listState, + ) { + itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, baseUser -> + DrawUser(baseUser, hasUserFlow, onSelect, onDelete, accountViewModel) + + HorizontalDivider( + thickness = DividerThickness, + ) + } + } + } + } + + Spacer(StdVertSpacer) +} + +@Composable +fun DrawUser( + baseUser: User, + hasUserFlow: (User) -> Flow, + onSelect: (User) -> Unit, + onDelete: (User) -> Unit, + accountViewModel: AccountViewModel, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = { onSelect(baseUser) }) + .padding( + start = 12.dp, + end = 12.dp, + top = 10.dp, + bottom = 10.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + ClickableUserPicture(baseUser, 55.dp, accountViewModel, Modifier, null) + + Column( + modifier = + Modifier + .padding(start = 10.dp) + .weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + UsernameDisplay( + baseUser, + accountViewModel = accountViewModel, + ) + HasUserTag(baseUser, hasUserFlow, onDelete) + } + + AboutDisplay(baseUser, accountViewModel) + } + } +} + +@Composable +private fun RowScope.HasUserTag( + baseUser: User, + hasUserFlow: (User) -> Flow, + onDelete: (User) -> Unit, +) { + val hasUserState by hasUserFlow(baseUser).collectAsStateWithLifecycle(false) + if (hasUserState) { + Spacer(StdHorzSpacer) + Text( + text = stringRes(id = R.string.in_the_list), + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + remember { + Modifier + .clip(SmallBorder) + .background(Color.Black) + .padding(horizontal = 5.dp) + }, + ) + Spacer(Modifier.weight(1f)) + IconButton( + modifier = Modifier.size(30.dp).padding(start = 10.dp), + onClick = { onDelete(baseUser) }, + ) { + Icon( + imageVector = Icons.Default.Cancel, + contentDescription = stringRes(id = R.string.remove), + modifier = Modifier.size(15.dp), + tint = LightRedColor, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt similarity index 58% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListScreen.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt index 8e3522c9a..ff72e8bd8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt @@ -18,16 +18,13 @@ * 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.lists.display +package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists +import android.content.Intent import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -38,12 +35,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Cancel import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults.cardElevation @@ -51,7 +45,6 @@ import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -62,60 +55,48 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.platform.LocalContext 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.dp -import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.AboutDisplay +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.ClearTextIcon -import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture -import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon -import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.AnimateOnNewSearch -import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.note.externalLinkForNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.DrawUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.PeopleListView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.RenderAddUserFieldAndSuggestions import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer -import com.vitorpamplona.amethyst.ui.theme.LightRedColor import com.vitorpamplona.amethyst.ui.theme.PopupUpEffect import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.SmallBorder -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdPadding -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -151,24 +132,7 @@ fun PeopleListScreen( containerColor = MaterialTheme.colorScheme.surface, ), ) - TabRow( - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.onBackground, - selectedTabIndex = pagerState.currentPage, - modifier = TabRowHeight, - ) { - val scope = rememberCoroutineScope() - Tab( - selected = pagerState.currentPage == 0, - onClick = { scope.launch { pagerState.animateScrollToPage(0) } }, - text = { Text(text = stringRes(R.string.private_members)) }, - ) - Tab( - selected = pagerState.currentPage == 1, - onClick = { scope.launch { pagerState.animateScrollToPage(1) } }, - text = { Text(text = stringRes(R.string.public_members)) }, - ) - } + TopAppTabs(viewModel, pagerState) } }, ) { padding -> @@ -190,7 +154,46 @@ fun PeopleListScreen( } @Composable -fun TitleAndDescription(viewModel: PeopleListViewModel) { +private fun TopAppTabs( + viewModel: PeopleListViewModel, + pagerState: PagerState, +) { + TabRow( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onBackground, + selectedTabIndex = pagerState.currentPage, + modifier = TabRowHeight, + ) { + val scope = rememberCoroutineScope() + Tab( + selected = pagerState.currentPage == 0, + onClick = { scope.launch { pagerState.animateScrollToPage(0) } }, + text = { + val list = viewModel.selectedList.collectAsStateWithLifecycle() + val labelPublic = + list.value?.let { + stringRes(R.string.public_members_count, it.publicMembers.size) + } ?: stringRes(R.string.public_members) + Text(labelPublic) + }, + ) + Tab( + selected = pagerState.currentPage == 1, + onClick = { scope.launch { pagerState.animateScrollToPage(1) } }, + text = { + val list = viewModel.selectedList.collectAsStateWithLifecycle() + val labelPrivate = + list.value?.let { + stringRes(R.string.private_members_count, it.privateMembersList.size) + } ?: stringRes(R.string.private_members) + Text(labelPrivate) + }, + ) + } +} + +@Composable +private fun TitleAndDescription(viewModel: PeopleListViewModel) { val selectedSetState = viewModel.selectedList.collectAsStateWithLifecycle() selectedSetState.value?.let { selectedSet -> Text( @@ -215,7 +218,7 @@ private fun ListViewAndEditColumn( pagerState = pagerState, modifier = Modifier.weight(1f), onDeleteUser = { user, isPrivate -> - accountViewModel.runIOCatching { + accountViewModel.launchSigner { viewModel.removeUserFromSet(user, isPrivate) } }, @@ -233,70 +236,20 @@ private fun RenderAddUserFieldAndSuggestions( pagerState: PagerState, accountViewModel: AccountViewModel, ) { - UserSearchDataSourceSubscription(viewModel.userSuggestions, accountViewModel) - - LaunchedEffect(Unit) { - launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { - viewModel.userSuggestions.invalidateData() - } - } - launch(Dispatchers.IO) { - LocalCache.live.deletedEventBundles.collect { - viewModel.userSuggestions.invalidateData() - } - } - } - - Spacer(HalfVertSpacer) - - var userName by remember(viewModel) { mutableStateOf(TextFieldValue(viewModel.userSuggestions.currentWord.value)) } - val focusManager = LocalFocusManager.current - - OutlinedTextField( - label = { Text(text = stringRes(R.string.search_and_add_a_user)) }, - modifier = Modifier.padding(horizontal = Size10dp).fillMaxWidth(), - value = userName, - onValueChange = { - userName = it - viewModel.userSuggestions.processCurrentWord(it.text) - }, - singleLine = true, - trailingIcon = { - IconButton( - onClick = { - userName = TextFieldValue("") - viewModel.userSuggestions.processCurrentWord("") - focusManager.clearFocus() - }, - ) { - ClearTextIcon() - } - }, - ) - - ShowUserSuggestions( - userSuggestions = viewModel.userSuggestions, + RenderAddUserFieldAndSuggestions( + viewModel.userSuggestions, hasUserFlow = { user -> - viewModel.hasUserFlow(user, pagerState.currentPage == 0) + viewModel.hasUserFlow(user, pagerState.currentPage == 1) }, - onSelect = { user -> - accountViewModel.runIOCatching { - viewModel.addUserToSet(user, pagerState.currentPage == 0) + addUserToSet = { user -> + accountViewModel.launchSigner { + viewModel.addUserToSet(user, pagerState.currentPage == 1) } - userName = - userName.copy( - selection = TextRange(0, userName.text.length), - ) }, - onDelete = { user -> - accountViewModel.runIOCatching { - viewModel.removeUserFromSet(user, pagerState.currentPage == 0) + removeUserFromSet = { user -> + accountViewModel.launchSigner { + viewModel.removeUserFromSet(user, pagerState.currentPage == 1) } - userName = - userName.copy( - selection = TextRange(0, userName.text.length), - ) }, accountViewModel = accountViewModel, ) @@ -317,9 +270,9 @@ private fun PeopleListPager( when (page) { 0 -> PeopleListView( - memberList = selectedSet.privateMembersList, + memberList = selectedSet.publicMembersList, onDeleteUser = { user -> - onDeleteUser(user, true) + onDeleteUser(user, false) }, modifier = Modifier.fillMaxSize(), accountViewModel = accountViewModel, @@ -328,9 +281,9 @@ private fun PeopleListPager( 1 -> PeopleListView( - memberList = selectedSet.publicMembersList, + memberList = selectedSet.privateMembersList, onDeleteUser = { user -> - onDeleteUser(user, false) + onDeleteUser(user, true) }, modifier = Modifier.fillMaxSize(), accountViewModel = accountViewModel, @@ -343,7 +296,7 @@ private fun PeopleListPager( @Composable @Preview(device = "spec:width=2160px,height=2940px,dpi=440") -fun FollowSetListViewPreview() { +private fun PeopleListViewPreview() { val accountViewModel = mockAccountViewModel() val user1: User = LocalCache.getOrCreateUser("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") @@ -356,7 +309,7 @@ fun FollowSetListViewPreview() { memberList = persistentListOf(user1, user2, user3), onDeleteUser = { user -> }, accountViewModel = accountViewModel, - nav = EmptyNav, + nav = EmptyNav(), ) Spacer(HalfVertSpacer) @@ -411,144 +364,25 @@ fun FollowSetListViewPreview() { } @Composable -fun ShowUserSuggestions( - userSuggestions: UserSuggestionState, - hasUserFlow: (User) -> Flow, - onSelect: (User) -> Unit, - onDelete: (User) -> Unit, - accountViewModel: AccountViewModel, -) { - val listState = rememberLazyListState() - - AnimateOnNewSearch(userSuggestions, listState) - - val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList()) - - if (suggestions.isNotEmpty()) { - Card( - modifier = Modifier.padding(start = 11.dp, end = 11.dp), - elevation = cardElevation(5.dp), - shape = PopupUpEffect, - ) { - LazyColumn( - contentPadding = PaddingValues(top = 10.dp), - modifier = - Modifier - .heightIn(0.dp, 200.dp), - state = listState, - ) { - itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, baseUser -> - DrawUser(baseUser, hasUserFlow, onSelect, onDelete, accountViewModel) - - HorizontalDivider( - thickness = DividerThickness, - ) - } - } - } - } - - Spacer(StdVertSpacer) -} - -@Composable -private fun DrawUser( - baseUser: User, - hasUserFlow: (User) -> Flow, - onSelect: (User) -> Unit, - onDelete: (User) -> Unit, - accountViewModel: AccountViewModel, -) { - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = { onSelect(baseUser) }) - .padding( - start = 12.dp, - end = 12.dp, - top = 10.dp, - bottom = 10.dp, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - ClickableUserPicture(baseUser, 55.dp, accountViewModel, Modifier, null) - - Column( - modifier = - Modifier - .padding(start = 10.dp) - .weight(1f), - verticalArrangement = Arrangement.Center, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - UsernameDisplay( - baseUser, - accountViewModel = accountViewModel, - ) - HasUserTag(baseUser, hasUserFlow, onDelete) - } - - AboutDisplay(baseUser, accountViewModel) - } - } -} - -@Composable -fun RowScope.HasUserTag( - baseUser: User, - hasUserFlow: (User) -> Flow, - onDelete: (User) -> Unit, -) { - val hasUserState by hasUserFlow(baseUser).collectAsStateWithLifecycle(false) - if (hasUserState) { - Spacer(StdHorzSpacer) - Text( - text = stringRes(id = R.string.in_the_list), - color = Color.White, - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = - remember { - Modifier - .clip(SmallBorder) - .background(Color.Black) - .padding(horizontal = 5.dp) - }, - ) - Spacer(Modifier.weight(1f)) - IconButton( - modifier = Modifier.size(30.dp).padding(start = 10.dp), - onClick = { onDelete(baseUser) }, - ) { - Icon( - imageVector = Icons.Default.Cancel, - contentDescription = stringRes(id = R.string.remove), - modifier = Modifier.size(15.dp), - tint = LightRedColor, - ) - } - } -} - -@Composable -fun ListActionsMenuButton( +private fun ListActionsMenuButton( viewModel: PeopleListViewModel, accountViewModel: AccountViewModel, nav: INav, ) { ListActionsMenuButton( + note = viewModel::selectedNote, + onEditList = { + nav.nav { Route.PeopleListMetadataEdit(viewModel.selectedDTag.value) } + }, onBroadcastList = { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { viewModel.loadNote()?.let { updatedSetNote -> accountViewModel.broadcast(updatedSetNote) } } }, onDeleteList = { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { viewModel.deleteFollowSet() } nav.popBack() @@ -557,7 +391,9 @@ fun ListActionsMenuButton( } @Composable -fun ListActionsMenuButton( +private fun ListActionsMenuButton( + note: () -> AddressableNote, + onEditList: () -> Unit, onBroadcastList: () -> Unit, onDeleteList: () -> Unit, ) { @@ -578,44 +414,65 @@ fun ListActionsMenuButton( onClick = { isActionListOpen.value = true }, ) { VerticalDotsIcon() - ListActionsMenu( - onCloseMenu = { isActionListOpen.value = false }, - isOpen = isActionListOpen.value, - onBroadcastList = onBroadcastList, - onDeleteList = onDeleteList, - ) - } -} -@Composable -fun ListActionsMenu( - onCloseMenu: () -> Unit, - isOpen: Boolean, - onBroadcastList: () -> Unit, - onDeleteList: () -> Unit, -) { - DropdownMenu( - expanded = isOpen, - onDismissRequest = onCloseMenu, - ) { - DropdownMenuItem( - text = { - Text("Broadcast List") - }, - onClick = { - onBroadcastList() - onCloseMenu() - }, - ) - HorizontalDivider(thickness = DividerThickness) - DropdownMenuItem( - text = { - Text("Delete List") - }, - onClick = { - onDeleteList() - onCloseMenu() - }, - ) + DropdownMenu( + expanded = isActionListOpen.value, + onDismissRequest = { isActionListOpen.value = false }, + ) { + val context = 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, + externalLinkForNote(note()), + ) + putExtra( + Intent.EXTRA_TITLE, + stringRes(context, R.string.quick_action_share_browser_link), + ) + } + + val shareIntent = + Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share)) + ContextCompat.startActivity(context, shareIntent, null) + isActionListOpen.value = false + }, + ) + HorizontalDivider(thickness = DividerThickness) + DropdownMenuItem( + text = { + Text(stringRes(R.string.follow_set_edit_list_metadata)) + }, + onClick = { + onEditList() + isActionListOpen.value = false + }, + ) + HorizontalDivider(thickness = DividerThickness) + DropdownMenuItem( + text = { + Text(stringRes(R.string.follow_set_broadcast)) + }, + onClick = { + onBroadcastList() + isActionListOpen.value = false + }, + ) + HorizontalDivider(thickness = DividerThickness) + DropdownMenuItem( + text = { + Text(stringRes(R.string.follow_set_delete)) + }, + onClick = { + onDeleteList() + isActionListOpen.value = false + }, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt index d36a0fb86..168f8484b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display +package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -60,6 +61,10 @@ class PeopleListViewModel : ViewModel() { }.flowOn(Dispatchers.IO) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + fun selectedAddress() = PeopleListEvent.createAddress(account.userProfile().pubkeyHex, selectedDTag.value) + + fun selectedNote() = account.cache.getOrCreateAddressableNote(selectedAddress()) + fun init( account: Account, selectedDTag: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt new file mode 100644 index 000000000..743fdf8c1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt @@ -0,0 +1,404 @@ +/** + * 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.lists.display.packs + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults.cardElevation +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +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.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.ClickableBox +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.note.ClearTextIcon +import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon +import com.vitorpamplona.amethyst.ui.note.externalLinkForNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.DrawUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.PeopleListView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.RenderAddUserFieldAndSuggestions +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer +import com.vitorpamplona.amethyst.ui.theme.PopupUpEffect +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.StdPadding +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FollowPackScreen( + selectedDTag: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val viewModel: FollowPackViewModel = viewModel() + viewModel.init(accountViewModel.account, selectedDTag) + + Scaffold( + topBar = { + Column { + TopAppBar( + title = { + TitleAndDescription(viewModel) + }, + navigationIcon = { + IconButton(nav::popBack) { + ArrowBackIcon() + } + }, + actions = { + ListActionsMenuButton(viewModel, accountViewModel, nav) + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + } + }, + ) { padding -> + ListViewAndEditColumn( + viewModel = viewModel, + modifier = + Modifier + .fillMaxSize() + .padding( + top = padding.calculateTopPadding(), + bottom = padding.calculateBottomPadding(), + ).consumeWindowInsets(padding) + .imePadding(), + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +private fun TitleAndDescription(viewModel: FollowPackViewModel) { + val selectedSetState = viewModel.selectedList.collectAsStateWithLifecycle() + selectedSetState.value?.let { selectedSet -> + Text( + text = selectedSet.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun ListViewAndEditColumn( + viewModel: FollowPackViewModel, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column(modifier = modifier) { + PeopleListPager( + viewModel = viewModel, + modifier = Modifier.weight(1f), + onDeleteUser = { user -> + accountViewModel.launchSigner { + viewModel.removeUserFromSet(user) + } + }, + accountViewModel = accountViewModel, + nav = nav, + ) + + RenderAddUserFieldAndSuggestions(viewModel, accountViewModel) + } +} + +@Composable +private fun RenderAddUserFieldAndSuggestions( + viewModel: FollowPackViewModel, + accountViewModel: AccountViewModel, +) { + RenderAddUserFieldAndSuggestions( + viewModel.userSuggestions, + hasUserFlow = { user -> + viewModel.hasUserFlow(user) + }, + addUserToSet = { user -> + accountViewModel.launchSigner { + viewModel.addUserToSet(user) + } + }, + removeUserFromSet = { user -> + accountViewModel.launchSigner { + viewModel.removeUserFromSet(user) + } + }, + accountViewModel = accountViewModel, + ) +} + +@Composable +private fun PeopleListPager( + viewModel: FollowPackViewModel, + modifier: Modifier, + onDeleteUser: (User) -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + val selectedSetState = viewModel.selectedList.collectAsStateWithLifecycle() + selectedSetState.value?.let { selectedSet -> + PeopleListView( + memberList = selectedSet.publicMembersList, + onDeleteUser = onDeleteUser, + modifier = modifier, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +private fun ListActionsMenuButton( + viewModel: FollowPackViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + ListActionsMenuButton( + note = viewModel::selectedNote, + onEditList = { + nav.nav { Route.FollowPackMetadataEdit(viewModel.selectedDTag.value) } + }, + onBroadcastList = { + accountViewModel.launchSigner { + viewModel.loadNote()?.let { updatedSetNote -> + accountViewModel.broadcast(updatedSetNote) + } + } + }, + onDeleteList = { + accountViewModel.launchSigner { + viewModel.deleteFollowSet() + } + nav.popBack() + }, + ) +} + +@Composable +private fun ListActionsMenuButton( + note: () -> AddressableNote, + onEditList: () -> Unit, + onBroadcastList: () -> Unit, + onDeleteList: () -> Unit, +) { + val isActionListOpen = remember { mutableStateOf(false) } + + ClickableBox( + modifier = + StdPadding + .size(30.dp) + .border( + width = Dp.Hairline, + color = ButtonDefaults.filledTonalButtonColors().containerColor, + shape = ButtonBorder, + ).background( + color = ButtonDefaults.filledTonalButtonColors().containerColor, + shape = ButtonBorder, + ), + onClick = { isActionListOpen.value = true }, + ) { + VerticalDotsIcon() + + DropdownMenu( + expanded = isActionListOpen.value, + onDismissRequest = { isActionListOpen.value = false }, + ) { + val context = 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, + externalLinkForNote(note()), + ) + putExtra( + Intent.EXTRA_TITLE, + stringRes(context, R.string.quick_action_share_browser_link), + ) + } + + val shareIntent = + Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share)) + ContextCompat.startActivity(context, shareIntent, null) + isActionListOpen.value = false + }, + ) + HorizontalDivider(thickness = DividerThickness) + DropdownMenuItem( + text = { + Text(stringRes(R.string.follow_pack_edit_list_metadata)) + }, + onClick = { + onEditList() + isActionListOpen.value = false + }, + ) + HorizontalDivider(thickness = DividerThickness) + DropdownMenuItem( + text = { + Text(stringRes(R.string.follow_pack_broadcast)) + }, + onClick = { + onBroadcastList() + isActionListOpen.value = false + }, + ) + HorizontalDivider(thickness = DividerThickness) + DropdownMenuItem( + text = { + Text(stringRes(R.string.follow_pack_delete)) + }, + onClick = { + onDeleteList() + isActionListOpen.value = false + }, + ) + } + } +} + +@Composable +@Preview(device = "spec:width=2160px,height=2940px,dpi=440") +fun FollowPackViewPreview() { + val accountViewModel = mockAccountViewModel() + + val user1: User = LocalCache.getOrCreateUser("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") + val user2: User = LocalCache.getOrCreateUser("ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b") + val user3: User = LocalCache.getOrCreateUser("7eb29c126b3628077e2e3d863b917a56b74293aa9d8a9abc26a40ba3f2866baf") + + ThemeComparisonRow { + Column { + PeopleListView( + memberList = persistentListOf(user1, user2, user3), + onDeleteUser = { user -> }, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + + Spacer(HalfVertSpacer) + + var userName by remember { mutableStateOf("") } + OutlinedTextField( + label = { Text(text = stringRes(R.string.search_and_add_a_user)) }, + modifier = + Modifier + .padding(horizontal = Size10dp) + .fillMaxWidth(), + value = userName, + onValueChange = { + userName = it + }, + singleLine = true, + trailingIcon = { + IconButton( + onClick = {}, + ) { + ClearTextIcon() + } + }, + ) + + Card( + modifier = Modifier.padding(horizontal = 10.dp), + elevation = cardElevation(5.dp), + shape = PopupUpEffect, + ) { + LazyColumn( + contentPadding = PaddingValues(top = 10.dp), + modifier = Modifier.heightIn(0.dp, 200.dp), + ) { + itemsIndexed(persistentListOf(user1, user2, user3), key = { _, item -> item.pubkeyHex }) { _, baseUser -> + DrawUser( + baseUser, + { MutableStateFlow(false) }, + {}, + {}, + accountViewModel, + ) + + HorizontalDivider( + thickness = DividerThickness, + ) + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt new file mode 100644 index 000000000..c51741b07 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt @@ -0,0 +1,102 @@ +/** + * 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.lists.display.packs + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest + +@Stable +class FollowPackViewModel : ViewModel() { + lateinit var account: Account + lateinit var userSuggestions: UserSuggestionState + + var userSuggestionFocus by mutableStateOf(null) + + val selectedDTag = MutableStateFlow("") + + @OptIn(ExperimentalCoroutinesApi::class) + val selectedList = + selectedDTag + .transformLatest { + emitAll( + account.followLists.selectListFlow(it).flowOn(Dispatchers.IO), + ) + }.flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + fun selectedAddress() = FollowListEvent.createAddress(account.userProfile().pubkeyHex, selectedDTag.value) + + fun selectedNote() = account.cache.getOrCreateAddressableNote(selectedAddress()) + + fun init( + account: Account, + selectedDTag: String, + ) { + if (!this::account.isInitialized || this.account != account) { + this.account = account + this.userSuggestions = UserSuggestionState(account, false) + } + + this.selectedDTag.tryEmit(selectedDTag) + } + + suspend fun deleteFollowSet() { + account.followLists.deleteFollowSet(selectedDTag.value, account) + } + + fun loadNote(): AddressableNote? = account.followLists.getPeopleListNote(selectedDTag.value) + + suspend fun removeUserFromSet(user: User) { + account.followLists.removeUserFromSet(user, selectedDTag.value, account) + } + + suspend fun addUserToSet(user: User) { + account.followLists.addUserToSet(user, selectedDTag.value, account) + } + + fun hasUserFlow(user: User): Flow = + selectedList.map { + if (it == null) { + false + } else { + user in it.publicMembers + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/FollowPackViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/FollowPackViewModel.kt new file mode 100644 index 000000000..83fe0d8c6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/FollowPackViewModel.kt @@ -0,0 +1,61 @@ +/** + * 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.lists.list + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Stable +class FollowPackViewModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel + + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + } + + fun listFlow() = accountViewModel.account.followLists.uiListFlow + + fun cloneItem( + followSet: PeopleList, + customName: String?, + customDescription: String?, + ) { + accountViewModel.launchSigner { + accountViewModel.account.followLists.cloneFollowSet( + currentFollowPack = followSet, + customCloneName = customName, + customCloneDescription = customDescription, + account = accountViewModel.account, + ) + } + } + + fun deleteItem(followSet: PeopleList) { + accountViewModel.launchSigner { + accountViewModel.account.followLists.deleteFollowSet( + identifierTag = followSet.identifierTag, + account = accountViewModel.account, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListFeedView.kt index 2cf015bfc..dd66d079d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListFeedView.kt @@ -22,40 +22,45 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding import com.vitorpamplona.amethyst.ui.theme.Size40dp +import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import kotlinx.coroutines.flow.StateFlow +import com.vitorpamplona.amethyst.ui.theme.grayText @Composable fun AllPeopleListFeedView( - listFlow: StateFlow>, - onOpenItem: (String) -> Unit = {}, - onRenameItem: (targetSet: PeopleList, newName: String) -> Unit, - onItemDescriptionChange: (peopleList: PeopleList, newDescription: String?) -> Unit, - onItemClone: (peopleList: PeopleList, customName: String?, customDesc: String?) -> Unit, - onDeleteItem: (peopleList: PeopleList) -> Unit, + peopleListModel: PeopleListViewModel, + followPackModel: FollowPackViewModel, + nav: INav, ) { - val followSetFeedState by listFlow.collectAsStateWithLifecycle() + val peopleListFeedState by peopleListModel.listFlow().collectAsStateWithLifecycle() + val followPackFeedState by followPackModel.listFlow().collectAsStateWithLifecycle() - if (followSetFeedState.isEmpty()) { + if (peopleListFeedState.isEmpty() && followPackFeedState.isEmpty()) { AllPeopleListFeedEmpty( message = stringRes(R.string.follow_set_empty_feed_msg), ) @@ -64,15 +69,84 @@ fun AllPeopleListFeedView( state = rememberLazyListState(), contentPadding = FeedPadding, ) { - itemsIndexed(followSetFeedState, key = { _, item -> item.identifierTag }) { _, list -> + stickyHeader { + Row( + modifier = MaxWidthWithHorzPadding, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = SpacedBy5dp, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringRes(R.string.follow_sets), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = stringRes(R.string.follow_sets_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + NewListButton( + onClick = { + nav.nav(Route.PeopleListMetadataEdit()) + }, + ) + } + } + itemsIndexed(peopleListFeedState, key = { _, item -> item.identifierTag }) { _, followSet -> PeopleListItem( - modifier = Modifier.fillMaxSize().animateItem(), - peopleList = list, - onClick = { onOpenItem(list.identifierTag) }, - onRename = { onRenameItem(list, it) }, - onDescriptionChange = { newDescription -> onItemDescriptionChange(list, newDescription) }, - onClone = { cloneName, cloneDescription -> onItemClone(list, cloneName, cloneDescription) }, - onDelete = { onDeleteItem(list) }, + modifier = + Modifier + .fillMaxSize() + .animateItem(), + peopleList = followSet, + onClick = { nav.nav(Route.MyPeopleListView(followSet.identifierTag)) }, + onEditMetadata = { nav.nav(Route.PeopleListMetadataEdit(followSet.identifierTag)) }, + onClone = { cloneName, cloneDescription -> peopleListModel.cloneItem(followSet, cloneName, cloneDescription) }, + onDelete = { peopleListModel.deleteItem(followSet) }, + ) + HorizontalDivider(thickness = DividerThickness) + } + stickyHeader { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, top = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = SpacedBy5dp, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringRes(R.string.discover_follows), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = stringRes(R.string.discover_follows_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + NewListButton( + onClick = { + nav.nav(Route.FollowPackMetadataEdit()) + }, + ) + } + } + itemsIndexed(followPackFeedState, key = { _, item -> item.identifierTag }) { _, followSet -> + PeopleListItem( + modifier = + Modifier + .fillMaxSize() + .animateItem(), + peopleList = followSet, + onClick = { nav.nav(Route.MyFollowPackView(followSet.identifierTag)) }, + onEditMetadata = { nav.nav(Route.FollowPackMetadataEdit(followSet.identifierTag)) }, + onClone = { cloneName, cloneDescription -> followPackModel.cloneItem(followSet, cloneName, cloneDescription) }, + onDelete = { followPackModel.deleteItem(followSet) }, ) HorizontalDivider(thickness = DividerThickness) } @@ -83,7 +157,9 @@ fun AllPeopleListFeedView( @Composable fun AllPeopleListFeedEmpty(message: String = stringRes(R.string.feed_is_empty)) { Column( - Modifier.fillMaxSize().padding(horizontal = Size40dp), + Modifier + .fillMaxSize() + .padding(horizontal = Size40dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListsScreen.kt index 627ea4534..a56ebdfaf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListsScreen.kt @@ -21,108 +21,50 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistAdd -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import kotlinx.coroutines.flow.StateFlow +import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp @Composable fun ListOfPeopleListsScreen( accountViewModel: AccountViewModel, nav: INav, ) { - ListOfPeopleListsScreen( - listFlow = accountViewModel.account.peopleLists.uiListFlow, - addItem = { title: String, description: String? -> - accountViewModel.runIOCatching { - accountViewModel.account.peopleLists.addFollowList( - listName = title, - listDescription = description, - account = accountViewModel.account, - ) - } - }, - openItem = { - nav.nav(Route.PeopleListView(it)) - }, - renameItem = { followSet, newValue -> - accountViewModel.runIOCatching { - accountViewModel.account.peopleLists.renameFollowList( - newName = newValue, - peopleList = followSet, - account = accountViewModel.account, - ) - } - }, - changeItemDescription = { followSet, newDescription -> - accountViewModel.runIOCatching { - accountViewModel.account.peopleLists.modifyFollowSetDescription( - newDescription = newDescription, - peopleList = followSet, - account = accountViewModel.account, - ) - } - }, - cloneItem = { followSet, customName, customDescription -> - accountViewModel.runIOCatching { - accountViewModel.account.peopleLists.cloneFollowSet( - currentPeopleList = followSet, - customCloneName = customName, - customCloneDescription = customDescription, - account = accountViewModel.account, - ) - } - }, - deleteItem = { followSet -> - accountViewModel.runIOCatching { - accountViewModel.account.peopleLists.deleteFollowSet( - identifierTag = followSet.identifierTag, - account = accountViewModel.account, - ) - } - }, - nav, - ) + val list: PeopleListViewModel = viewModel() + list.init(accountViewModel) + + val pack: FollowPackViewModel = viewModel() + pack.init(accountViewModel) + + ListOfPeopleListsScreen(list, pack, nav) } @Composable fun ListOfPeopleListsScreen( - listFlow: StateFlow>, - addItem: (title: String, description: String?) -> Unit, - openItem: (identifier: String) -> Unit, - renameItem: (peopleList: PeopleList, newName: String) -> Unit, - changeItemDescription: (peopleList: PeopleList, newDescription: String?) -> Unit, - cloneItem: (peopleList: PeopleList, customName: String?, customDesc: String?) -> Unit, - deleteItem: (peopleList: PeopleList) -> Unit, + list: PeopleListViewModel, + pack: FollowPackViewModel, nav: INav, ) { Scaffold( topBar = { TopBarWithBackButton(stringRes(R.string.my_lists), nav::popBack) }, - floatingActionButton = { - PeopleListFabsAndMenu( - onAddSet = addItem, - ) - }, ) { paddingValues -> Column( Modifier @@ -131,47 +73,20 @@ fun ListOfPeopleListsScreen( bottom = paddingValues.calculateBottomPadding(), ).fillMaxHeight(), ) { - AllPeopleListFeedView( - listFlow = listFlow, - onOpenItem = openItem, - onRenameItem = renameItem, - onItemDescriptionChange = changeItemDescription, - onItemClone = cloneItem, - onDeleteItem = deleteItem, - ) + AllPeopleListFeedView(list, pack, nav) } } } @Composable -private fun PeopleListFabsAndMenu(onAddSet: (name: String, description: String?) -> Unit) { - val isSetAdditionDialogOpen = remember { mutableStateOf(false) } - - ExtendedFloatingActionButton( - text = { - Text(text = stringRes(R.string.follow_set_create_btn_label)) - }, - icon = { +fun NewListButton(onClick: () -> Unit) { + OutlinedButton(onClick = onClick) { + Row(horizontalArrangement = SpacedBy5dp, verticalAlignment = Alignment.CenterVertically) { Icon( imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = null, ) - }, - onClick = { - isSetAdditionDialogOpen.value = true - }, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) - - if (isSetAdditionDialogOpen.value) { - NewPeopleListCreationDialog( - onDismiss = { - isSetAdditionDialogOpen.value = false - }, - onCreateList = { name, description -> - onAddSet(name, description) - }, - ) + Text(stringRes(R.string.follow_set_create_btn_label)) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/NewPeopleListCreationDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/NewPeopleListCreationDialog.kt index 0ce4be896..edc7afd67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/NewPeopleListCreationDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/NewPeopleListCreationDialog.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer @Composable fun NewPeopleListCreationDialog( + title: Int = R.string.follow_set_creation_dialog_title, modifier: Modifier = Modifier, onDismiss: () -> Unit, onCreateList: (name: String, description: String?) -> Unit, @@ -56,7 +57,7 @@ fun NewPeopleListCreationDialog( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = stringRes(R.string.follow_set_creation_dialog_title), + text = stringRes(title), ) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt index dd6b32788..5c9b84e41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt @@ -81,8 +81,9 @@ private fun PeopleListItemPreview() { val samplePeopleList1 = PeopleList( identifierTag = "00001-2222", - title = "Sample List Title", + title = "Sample List Title, Very long title, very very very long", description = "Sample List Description", + image = "http://some.com/image.png", emptySet(), emptySet(), ) @@ -92,6 +93,7 @@ private fun PeopleListItemPreview() { identifierTag = "00001-2223", title = "Sample List Title", description = "Sample List Description", + image = "http://some.com/image.png", setOf(user1, user3), emptySet(), ) @@ -101,6 +103,7 @@ private fun PeopleListItemPreview() { identifierTag = "00001-2224", title = "Sample List Title", description = "Sample List Description", + image = "http://some.com/image.png", emptySet(), setOf(user1, user3), ) @@ -110,6 +113,7 @@ private fun PeopleListItemPreview() { identifierTag = "00001-2225", title = "Sample List Title", description = "Sample List Description", + image = "http://some.com/image.png", setOf(user3), setOf(user1, user2, user3), ) @@ -120,8 +124,7 @@ private fun PeopleListItemPreview() { modifier = Modifier, peopleList = samplePeopleList1, onClick = {}, - onRename = {}, - onDescriptionChange = { }, + onEditMetadata = {}, onClone = { newName, newDesc -> }, onDelete = {}, ) @@ -129,8 +132,7 @@ private fun PeopleListItemPreview() { modifier = Modifier, peopleList = samplePeopleList2, onClick = {}, - onRename = {}, - onDescriptionChange = { }, + onEditMetadata = {}, onClone = { newName, newDesc -> }, onDelete = {}, ) @@ -138,8 +140,7 @@ private fun PeopleListItemPreview() { modifier = Modifier, peopleList = samplePeopleList3, onClick = {}, - onRename = {}, - onDescriptionChange = { }, + onEditMetadata = {}, onClone = { newName, newDesc -> }, onDelete = {}, ) @@ -147,8 +148,7 @@ private fun PeopleListItemPreview() { modifier = Modifier, peopleList = samplePeopleList4, onClick = {}, - onRename = {}, - onDescriptionChange = { }, + onEditMetadata = {}, onClone = { newName, newDesc -> }, onDelete = {}, ) @@ -161,8 +161,7 @@ fun PeopleListItem( modifier: Modifier = Modifier, peopleList: PeopleList, onClick: () -> Unit, - onRename: (String) -> Unit, - onDescriptionChange: (String?) -> Unit, + onEditMetadata: () -> Unit, onClone: (customName: String?, customDescription: String?) -> Unit, onDelete: () -> Unit, ) { @@ -173,7 +172,12 @@ fun PeopleListItem( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { - Text(peopleList.title, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + modifier = Modifier.weight(1f), + text = peopleList.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) Column( modifier = NoSoTinyBorders, @@ -181,10 +185,7 @@ fun PeopleListItem( horizontalAlignment = Alignment.End, ) { PeopleListOptionsButton( - peopleListName = peopleList.title, - peopleListDescription = peopleList.description, - onListRename = onRename, - onListDescriptionChange = onDescriptionChange, + onListEditMetadata = onEditMetadata, onListCloneCreate = onClone, onListDelete = onDelete, ) @@ -271,10 +272,7 @@ fun DisplayParticipantNumberAndStatus( @Composable private fun PeopleListOptionsButton( modifier: Modifier = Modifier, - peopleListName: String, - peopleListDescription: String?, - onListRename: (String) -> Unit, - onListDescriptionChange: (String?) -> Unit, + onListEditMetadata: () -> Unit, onListCloneCreate: (optionalName: String?, optionalDec: String?) -> Unit, onListDelete: () -> Unit, ) { @@ -286,35 +284,23 @@ private fun PeopleListOptionsButton( VerticalDotsIcon() ListOptionsMenu( - setName = peopleListName, - setDescription = peopleListDescription, isExpanded = isMenuOpen.value, - onDismiss = { isMenuOpen.value = false }, - onListRename = onListRename, - onListDescriptionChange = onListDescriptionChange, + onListEditMetadata = onListEditMetadata, onListClone = onListCloneCreate, onDelete = onListDelete, + onDismiss = { isMenuOpen.value = false }, ) } } @Composable private fun ListOptionsMenu( - modifier: Modifier = Modifier, isExpanded: Boolean, - setName: String, - setDescription: String?, - onListRename: (String) -> Unit, - onListDescriptionChange: (String?) -> Unit, + onListEditMetadata: () -> Unit, onListClone: (optionalNewName: String?, optionalNewDesc: String?) -> Unit, onDelete: () -> Unit, onDismiss: () -> Unit, ) { - val isRenameDialogOpen = remember { mutableStateOf(false) } - val renameString = remember { mutableStateOf("") } - - val isDescriptionModDialogOpen = remember { mutableStateOf(false) } - val isCopyDialogOpen = remember { mutableStateOf(false) } val optionalCloneName = remember { mutableStateOf(null) } val optionalCloneDescription = remember { mutableStateOf(null) } @@ -325,19 +311,10 @@ private fun ListOptionsMenu( ) { DropdownMenuItem( text = { - Text(text = stringRes(R.string.follow_set_rename_btn_label)) + Text(text = stringRes(R.string.follow_set_edit_list_metadata)) }, onClick = { - isRenameDialogOpen.value = true - onDismiss() - }, - ) - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.follow_set_desc_modify_label)) - }, - onClick = { - isDescriptionModDialogOpen.value = true + onListEditMetadata() onDismiss() }, ) @@ -360,28 +337,6 @@ private fun ListOptionsMenu( ) } - if (isRenameDialogOpen.value) { - ListRenameDialog( - currentName = setName, - newName = renameString.value, - onStringRenameChange = { - renameString.value = it - }, - onDismissDialog = { isRenameDialogOpen.value = false }, - onListRename = { - onListRename(renameString.value) - }, - ) - } - - if (isDescriptionModDialogOpen.value) { - ListModifyDescriptionDialog( - currentDescription = setDescription, - onDismissDialog = { isDescriptionModDialogOpen.value = false }, - onModifyDescription = onListDescriptionChange, - ) - } - if (isCopyDialogOpen.value) { ListCloneDialog( optionalNewName = optionalCloneName.value, @@ -465,9 +420,9 @@ private fun ListModifyDescriptionDialog( modifier: Modifier = Modifier, currentDescription: String?, onDismissDialog: () -> Unit, - onModifyDescription: (String?) -> Unit, + onModifyDescription: (String) -> Unit, ) { - val updatedDescription = remember { mutableStateOf(null) } + val updatedDescription = remember { mutableStateOf("") } val modifyIndicatorLabel = if (currentDescription == null) { @@ -504,7 +459,7 @@ private fun ListModifyDescriptionDialog( fontStyle = FontStyle.Italic, ) TextField( - value = updatedDescription.value ?: "", + value = updatedDescription.value, onValueChange = { updatedDescription.value = it }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListViewModel.kt new file mode 100644 index 000000000..184a8a926 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListViewModel.kt @@ -0,0 +1,61 @@ +/** + * 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.lists.list + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Stable +class PeopleListViewModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel + + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + } + + fun listFlow() = accountViewModel.account.peopleLists.uiListFlow + + fun cloneItem( + followSet: PeopleList, + customName: String?, + customDescription: String?, + ) { + accountViewModel.launchSigner { + accountViewModel.account.peopleLists.cloneFollowSet( + currentPeopleList = followSet, + customCloneName = customName, + customCloneDescription = customDescription, + account = accountViewModel.account, + ) + } + } + + fun deleteItem(followSet: PeopleList) { + accountViewModel.launchSigner { + accountViewModel.account.peopleLists.deleteFollowSet( + identifierTag = followSet.identifierTag, + account = accountViewModel.account, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataScreen.kt new file mode 100644 index 000000000..47e33051d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataScreen.kt @@ -0,0 +1,271 @@ +/** + * 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.lists.list.metadata + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions + +@Composable +fun FollowPackMetadataScreen( + selectedDTag: String?, + accountViewModel: AccountViewModel, + nav: INav, +) { + val postViewModel: FollowPackMetadataViewModel = viewModel() + postViewModel.init(accountViewModel) + + if (selectedDTag != null) { + LaunchedEffect(postViewModel) { + postViewModel.load(selectedDTag) + } + } else { + LaunchedEffect(postViewModel) { + postViewModel.new() + } + } + + FollowPackMetadataScaffold( + postViewModel = postViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Preview(device = "spec:width=2160px,height=2340px,dpi=440") +@Composable +private fun DialogContentPreview() { + val accountViewModel = mockAccountViewModel() + val postViewModel: FollowPackMetadataViewModel = viewModel() + postViewModel.init(accountViewModel) + + ThemeComparisonRow { + FollowPackMetadataScaffold( + postViewModel = postViewModel, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FollowPackMetadataScaffold( + postViewModel: FollowPackMetadataViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + topBar = { + FollowPackMetadataTopBar( + postViewModel = postViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) { pad -> + LazyColumn( + Modifier + .fillMaxSize() + .padding( + start = 10.dp, + end = 10.dp, + top = pad.calculateTopPadding(), + bottom = pad.calculateBottomPadding(), + ).consumeWindowInsets(pad) + .imePadding(), + ) { + item { + SettingsCategory( + R.string.follow_pack_title, + R.string.follow_pack_explainer, + SettingsCategoryFirstModifier, + ) + + ListName(postViewModel) + + Spacer(modifier = DoubleVertSpacer) + + Picture(postViewModel, accountViewModel) + + Spacer(modifier = DoubleVertSpacer) + + Description(postViewModel) + } + } + } +} + +@Composable +fun FollowPackMetadataTopBar( + postViewModel: FollowPackMetadataViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (postViewModel.isNewPack) { + CreatingTopBar( + titleRes = R.string.follow_pack_creation_dialog_title, + isActive = postViewModel::canPost, + onCancel = { + postViewModel.clear() + nav.popBack() + }, + onPost = { + try { + postViewModel.createOrUpdate() + nav.popBack() + } catch (e: SignerExceptions.ReadOnlyException) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_sign_events, + ) + } + }, + ) + } else { + SavingTopBar( + titleRes = R.string.follow_pack_edit_list_metadata, + isActive = postViewModel::canPost, + onCancel = { + postViewModel.clear() + nav.popBack() + }, + onPost = { + try { + postViewModel.createOrUpdate() + nav.popBack() + } catch (e: SignerExceptions.ReadOnlyException) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_sign_events, + ) + } + }, + ) + } +} + +@Composable +private fun Description(postViewModel: FollowPackMetadataViewModel) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.follow_pack_creation_desc_label)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.description.value, + onValueChange = { postViewModel.description.value = it }, + placeholder = { + Text( + text = stringRes(R.string.about_us), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + minLines = 3, + ) +} + +@Composable +private fun Picture( + postViewModel: FollowPackMetadataViewModel, + accountViewModel: AccountViewModel, +) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.picture_url)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.picture.value, + onValueChange = { postViewModel.picture.value = it }, + placeholder = { + Text( + text = "http://mygroup.com/logo.jpg", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + leadingIcon = { + val context = LocalContext.current + SelectSingleFromGallery( + isUploading = postViewModel.isUploadingImageForPicture, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(start = 2.dp), + ) { + postViewModel.uploadForPicture(it, context, onError = accountViewModel.toastManager::toast) + } + }, + ) +} + +@Composable +private fun ListName(postViewModel: FollowPackMetadataViewModel) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.follow_pack_creation_name_label)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.name.value, + onValueChange = { postViewModel.name.value = it }, + placeholder = { + Text( + text = stringRes(R.string.follow_pack_copy_name_label), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt new file mode 100644 index 000000000..2cb4ee064 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt @@ -0,0 +1,187 @@ +/** + * 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.lists.list.metadata + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader +import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +@Stable +class FollowPackMetadataViewModel : ViewModel() { + private lateinit var accountViewModel: AccountViewModel + private lateinit var account: Account + + var peopleList by mutableStateOf(null) + val isNewPack by derivedStateOf { peopleList == null } + + val name = mutableStateOf(TextFieldValue()) + val picture = mutableStateOf(TextFieldValue()) + val description = mutableStateOf(TextFieldValue()) + + var isUploadingImageForPicture by mutableStateOf(false) + + val canPost by derivedStateOf { + name.value.text.isNotBlank() + } + + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account + } + + fun new() { + peopleList = null + clear() + } + + fun load(dTag: String) { + peopleList = account.followLists.selectList(dTag) + name.value = TextFieldValue(peopleList?.title ?: "") + picture.value = TextFieldValue(peopleList?.image ?: "") + description.value = TextFieldValue(peopleList?.description ?: "") + } + + fun createOrUpdate() { + accountViewModel.launchSigner { + val peopleList = peopleList + if (peopleList == null) { + val newListIdentifier = + accountViewModel.account.followLists.addFollowList( + name = name.value.text, + desc = description.value.text, + image = picture.value.text, + account = accountViewModel.account, + ) + } else { + accountViewModel.account.followLists.updateMetadata( + name = name.value.text, + desc = description.value.text, + image = picture.value.text, + peopleList = peopleList, + account = accountViewModel.account, + ) + } + + clear() + } + } + + fun clear() { + name.value = TextFieldValue() + picture.value = TextFieldValue() + description.value = TextFieldValue() + } + + fun uploadForPicture( + uri: SelectedMedia, + context: Context, + onError: (String, String) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + upload( + uri, + context, + onUploading = { isUploadingImageForPicture = it }, + onUploaded = { picture.value = TextFieldValue(it) }, + onError = onError, + ) + } + } + + private suspend fun upload( + galleryUri: SelectedMedia, + context: Context, + onUploading: (Boolean) -> Unit, + onUploaded: (String) -> Unit, + onError: (String, String) -> Unit, + ) { + onUploading(true) + + val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext) + + try { + val result = + if (account.settings.defaultFileServer.type == ServerType.NIP96) { + Nip96Uploader().upload( + uri = compResult.uri, + contentType = compResult.contentType, + size = compResult.size, + alt = null, + sensitiveContent = null, + serverBaseUrl = account.settings.defaultFileServer.baseUrl, + okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, + onProgress = {}, + httpAuth = account::createHTTPAuthorization, + context = context, + ) + } else { + BlossomUploader().upload( + uri = compResult.uri, + contentType = compResult.contentType, + size = compResult.size, + alt = null, + sensitiveContent = null, + serverBaseUrl = account.settings.defaultFileServer.baseUrl, + okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, + httpAuth = account::createBlossomUploadAuth, + context = context, + ) + } + + if (result.url != null) { + onUploading(false) + onUploaded(result.url) + } else { + onUploading(false) + onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading)) + } + } catch (_: SignerExceptions.ReadOnlyException) { + onUploading(false) + onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.login_with_a_private_key_to_be_able_to_upload)) + } catch (e: Exception) { + if (e is CancellationException) throw e + onUploading(false) + onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataScreen.kt new file mode 100644 index 000000000..fa6e1925e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataScreen.kt @@ -0,0 +1,271 @@ +/** + * 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.lists.list.metadata + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions + +@Composable +fun PeopleListMetadataScreen( + selectedDTag: String?, + accountViewModel: AccountViewModel, + nav: INav, +) { + val postViewModel: PeopleListMetadataViewModel = viewModel() + postViewModel.init(accountViewModel) + + if (selectedDTag != null) { + LaunchedEffect(postViewModel) { + postViewModel.load(selectedDTag) + } + } else { + LaunchedEffect(postViewModel) { + postViewModel.new() + } + } + + PeopleListMetadataScaffold( + postViewModel = postViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Preview(device = "spec:width=2160px,height=2340px,dpi=440") +@Composable +private fun DialogContentPreview() { + val accountViewModel = mockAccountViewModel() + val postViewModel: PeopleListMetadataViewModel = viewModel() + postViewModel.init(accountViewModel) + + ThemeComparisonRow { + PeopleListMetadataScaffold( + postViewModel = postViewModel, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PeopleListMetadataScaffold( + postViewModel: PeopleListMetadataViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + topBar = { + PeopleListMetadataTopBar( + postViewModel = postViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) { pad -> + LazyColumn( + Modifier + .fillMaxSize() + .padding( + start = 10.dp, + end = 10.dp, + top = pad.calculateTopPadding(), + bottom = pad.calculateBottomPadding(), + ).consumeWindowInsets(pad) + .imePadding(), + ) { + item { + SettingsCategory( + R.string.people_list_title, + R.string.people_list_explainer, + SettingsCategoryFirstModifier, + ) + + ListName(postViewModel) + + Spacer(modifier = DoubleVertSpacer) + + Picture(postViewModel, accountViewModel) + + Spacer(modifier = DoubleVertSpacer) + + Description(postViewModel) + } + } + } +} + +@Composable +fun PeopleListMetadataTopBar( + postViewModel: PeopleListMetadataViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (postViewModel.isNewList) { + CreatingTopBar( + titleRes = R.string.follow_set_creation_dialog_title, + isActive = postViewModel::canPost, + onCancel = { + postViewModel.clear() + nav.popBack() + }, + onPost = { + try { + postViewModel.createOrUpdate() + nav.popBack() + } catch (e: SignerExceptions.ReadOnlyException) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_sign_events, + ) + } + }, + ) + } else { + SavingTopBar( + titleRes = R.string.follow_set_edit_list_metadata, + isActive = postViewModel::canPost, + onCancel = { + postViewModel.clear() + nav.popBack() + }, + onPost = { + try { + postViewModel.createOrUpdate() + nav.popBack() + } catch (e: SignerExceptions.ReadOnlyException) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_sign_events, + ) + } + }, + ) + } +} + +@Composable +private fun Description(postViewModel: PeopleListMetadataViewModel) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.follow_set_creation_desc_label)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.description.value, + onValueChange = { postViewModel.description.value = it }, + placeholder = { + Text( + text = stringRes(R.string.about_us), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + minLines = 3, + ) +} + +@Composable +private fun Picture( + postViewModel: PeopleListMetadataViewModel, + accountViewModel: AccountViewModel, +) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.picture_url)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.picture.value, + onValueChange = { postViewModel.picture.value = it }, + placeholder = { + Text( + text = "http://mygroup.com/logo.jpg", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + leadingIcon = { + val context = LocalContext.current + SelectSingleFromGallery( + isUploading = postViewModel.isUploadingImageForPicture, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(start = 2.dp), + ) { + postViewModel.uploadForPicture(it, context, onError = accountViewModel.toastManager::toast) + } + }, + ) +} + +@Composable +private fun ListName(postViewModel: PeopleListMetadataViewModel) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.follow_set_creation_name_label)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.name.value, + onValueChange = { postViewModel.name.value = it }, + placeholder = { + Text( + text = stringRes(R.string.follow_set_copy_name_label), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt new file mode 100644 index 000000000..5063e6e36 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt @@ -0,0 +1,188 @@ +/** + * 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.lists.list.metadata + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader +import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +@Stable +class PeopleListMetadataViewModel : ViewModel() { + private lateinit var accountViewModel: AccountViewModel + private lateinit var account: Account + + var peopleList by mutableStateOf(null) + val isNewList by derivedStateOf { peopleList == null } + + val name = mutableStateOf(TextFieldValue()) + val picture = mutableStateOf(TextFieldValue()) + val description = mutableStateOf(TextFieldValue()) + + var isUploadingImageForPicture by mutableStateOf(false) + + val canPost by derivedStateOf { + name.value.text.isNotBlank() + } + + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account + } + + fun new() { + peopleList = null + clear() + } + + fun load(dTag: String) { + peopleList = account.peopleLists.selectList(dTag) + name.value = TextFieldValue(peopleList?.title ?: "") + picture.value = TextFieldValue(peopleList?.image ?: "") + description.value = TextFieldValue(peopleList?.description ?: "") + } + + fun isNewChannel() = peopleList == null + + fun createOrUpdate() { + accountViewModel.launchSigner { + val peopleList = peopleList + if (peopleList == null) { + accountViewModel.account.peopleLists.addFollowList( + listName = name.value.text, + listDescription = description.value.text, + listImage = picture.value.text, + account = accountViewModel.account, + ) + } else { + accountViewModel.account.peopleLists.updateMetadata( + listName = name.value.text, + listDescription = description.value.text, + listImage = picture.value.text, + peopleList = peopleList, + account = accountViewModel.account, + ) + } + + clear() + } + } + + fun clear() { + name.value = TextFieldValue() + picture.value = TextFieldValue() + description.value = TextFieldValue() + } + + fun uploadForPicture( + uri: SelectedMedia, + context: Context, + onError: (String, String) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + upload( + uri, + context, + onUploading = { isUploadingImageForPicture = it }, + onUploaded = { picture.value = TextFieldValue(it) }, + onError = onError, + ) + } + } + + private suspend fun upload( + galleryUri: SelectedMedia, + context: Context, + onUploading: (Boolean) -> Unit, + onUploaded: (String) -> Unit, + onError: (String, String) -> Unit, + ) { + onUploading(true) + + val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext) + + try { + val result = + if (account.settings.defaultFileServer.type == ServerType.NIP96) { + Nip96Uploader().upload( + uri = compResult.uri, + contentType = compResult.contentType, + size = compResult.size, + alt = null, + sensitiveContent = null, + serverBaseUrl = account.settings.defaultFileServer.baseUrl, + okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, + onProgress = {}, + httpAuth = account::createHTTPAuthorization, + context = context, + ) + } else { + BlossomUploader().upload( + uri = compResult.uri, + contentType = compResult.contentType, + size = compResult.size, + alt = null, + sensitiveContent = null, + serverBaseUrl = account.settings.defaultFileServer.baseUrl, + okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, + httpAuth = account::createBlossomUploadAuth, + context = context, + ) + } + + if (result.url != null) { + onUploading(false) + onUploaded(result.url) + } else { + onUploading(false) + onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading)) + } + } catch (_: SignerExceptions.ReadOnlyException) { + onUploading(false) + onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.login_with_a_private_key_to_be_able_to_upload)) + } catch (e: Exception) { + if (e is CancellationException) throw e + onUploading(false) + onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserScreen.kt similarity index 67% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserScreen.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserScreen.kt index 935f3f1e1..9333c185f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserScreen.kt @@ -26,15 +26,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.recalculateWindowInsets -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -49,13 +42,12 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.NewPeopleListCreationDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.HexKey @OptIn(ExperimentalMaterial3Api::class) @Composable -fun EditPeopleListScreen( +fun FollowListAndPackAndUserScreen( userToAddOrRemove: HexKey, accountViewModel: AccountViewModel, nav: INav, @@ -72,22 +64,19 @@ fun EditPeopleListScreen( } userBase?.let { - EditPeopleListScreen(it, accountViewModel, nav) + FollowListAndPackAndUserScreen(it, accountViewModel, nav) } } @OptIn(ExperimentalMaterial3Api::class) @Composable -fun EditPeopleListScreen( +fun FollowListAndPackAndUserScreen( userToAddOrRemove: User, accountViewModel: AccountViewModel, nav: INav, ) { Scaffold( modifier = Modifier.fillMaxSize().recalculateWindowInsets(), - floatingActionButton = { - PeopleListAndUserFab(accountViewModel) - }, topBar = { val userName by observeUserName(userToAddOrRemove, accountViewModel) TopBarWithBackButton( @@ -105,45 +94,7 @@ fun EditPeopleListScreen( ).consumeWindowInsets(contentPadding) .imePadding(), ) { - PeopleListAndUserView(userToAddOrRemove, accountViewModel, nav) + FollowListAndPackAndUserView(userToAddOrRemove, accountViewModel, nav) } } } - -@Composable -private fun PeopleListAndUserFab(accountViewModel: AccountViewModel) { - var isOpen by remember { mutableStateOf(false) } - - ExtendedFloatingActionButton( - text = { - Text(text = stringRes(R.string.follow_set_create_btn_label)) - }, - icon = { - Icon( - imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, - contentDescription = null, - ) - }, - onClick = { isOpen = !isOpen }, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) - - if (isOpen) { - NewPeopleListCreationDialog( - onDismiss = { - isOpen = false - }, - onCreateList = { name, description -> - accountViewModel.runIOCatching { - accountViewModel.account.peopleLists.addFollowList( - listName = name, - listDescription = description, - account = accountViewModel.account, - ) - } - isOpen = false - }, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserView.kt new file mode 100644 index 000000000..cffd299fc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserView.kt @@ -0,0 +1,198 @@ +/** + * 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.lists.memberEdit + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.NewListButton +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding +import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.grayText + +@Composable +fun FollowListAndPackAndUserView( + userToAddOrRemove: User, + accountViewModel: AccountViewModel, + nav: INav, +) { + val followSetsState by accountViewModel.account.peopleLists.uiListFlow + .collectAsStateWithLifecycle() + val followPackFeedState by accountViewModel.account.followLists.uiListFlow + .collectAsStateWithLifecycle() + + if (followSetsState.isEmpty() && followPackFeedState.isEmpty()) { + Column( + Modifier + .fillMaxWidth() + .fillMaxHeight(0.5f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(text = stringRes(R.string.follow_set_empty_dialog_msg)) + Spacer(modifier = StdVertSpacer) + } + } else { + val userName by observeUserName(userToAddOrRemove, accountViewModel) + + LazyColumn(modifier = Modifier.fillMaxWidth()) { + stickyHeader { + Row( + modifier = MaxWidthWithHorzPadding, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = SpacedBy5dp, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringRes(R.string.follow_sets), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = stringRes(R.string.follow_sets_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + NewListButton( + onClick = { nav.nav(Route.PeopleListMetadataEdit()) }, + ) + } + } + itemsIndexed(followSetsState, key = { _, item -> item.identifierTag }) { _, list -> + PeopleListAndUserItem( + modifier = Modifier.fillMaxWidth(), + listHeader = list.title, + userName = userName, + userIsPrivateMember = list.privateMembers.contains(userToAddOrRemove), + userIsPublicMember = list.publicMembers.contains(userToAddOrRemove), + onRemoveUser = { + accountViewModel.launchSigner { + accountViewModel.account.peopleLists.removeUserFromSet( + userToAddOrRemove, + isPrivate = list.privateMembers.contains(userToAddOrRemove), + list.identifierTag, + accountViewModel.account, + ) + } + }, + privateMemberSize = list.privateMembers.size, + publicMemberSize = list.publicMembers.size, + onClick = { + nav.nav(Route.MyPeopleListView(list.identifierTag)) + }, + onAddUserToList = { userShouldBePrivate -> + accountViewModel.launchSigner { + accountViewModel.account.peopleLists.addUserToSet( + userToAddOrRemove, + list.identifierTag, + userShouldBePrivate, + accountViewModel.account, + ) + } + }, + ) + HorizontalDivider(thickness = DividerThickness) + } + stickyHeader { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, top = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = SpacedBy5dp, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringRes(R.string.discover_follows), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = stringRes(R.string.discover_follows_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + NewListButton( + onClick = { nav.nav(Route.FollowPackMetadataEdit()) }, + ) + } + } + itemsIndexed(followPackFeedState, key = { _, item -> item.identifierTag }) { _, list -> + FollowPackAndUserItem( + modifier = Modifier.fillMaxWidth(), + listHeader = list.title, + userName = userName, + isMember = list.publicMembers.contains(userToAddOrRemove), + onClick = { + nav.nav(Route.MyFollowPackView(list.identifierTag)) + }, + onRemoveUser = { + accountViewModel.launchSigner { + accountViewModel.account.followLists.removeUserFromSet( + userToAddOrRemove, + list.identifierTag, + accountViewModel.account, + ) + } + }, + memberSize = list.publicMembers.size, + onAddUserToList = { + accountViewModel.launchSigner { + accountViewModel.account.followLists.addUserToSet( + userToAddOrRemove, + list.identifierTag, + accountViewModel.account, + ) + } + }, + ) + HorizontalDivider(thickness = DividerThickness) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowPackAndUserItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowPackAndUserItem.kt new file mode 100644 index 000000000..40fdd45b1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowPackAndUserItem.kt @@ -0,0 +1,216 @@ +/** + * 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.lists.memberEdit + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.PersonRemove +import androidx.compose.material.icons.outlined.Groups +import androidx.compose.material.icons.outlined.Public +import androidx.compose.material.icons.outlined.RemoveCircleOutline +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.DisplayParticipantNumberAndStatus +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding +import com.vitorpamplona.amethyst.ui.theme.Size15Modifier +import com.vitorpamplona.amethyst.ui.theme.Size50ModifierOffset10 +import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Preview +@Composable +fun FollowPackAndUserMemberPreview() { + ThemeComparisonColumn { + FollowPackAndUserItem( + modifier = Modifier.fillMaxWidth(), + listHeader = "list title", + userName = "User", + isMember = true, + memberSize = 2, + onAddUserToList = {}, + onClick = {}, + onRemoveUser = {}, + ) + } +} + +@Preview +@Composable +fun FollowPackAndUserNotMemberPreview() { + ThemeComparisonColumn { + FollowPackAndUserItem( + modifier = Modifier.fillMaxWidth(), + listHeader = "list title", + userName = "User", + isMember = false, + memberSize = 2, + onAddUserToList = {}, + onClick = {}, + onRemoveUser = {}, + ) + } +} + +@Composable +fun FollowPackAndUserItem( + modifier: Modifier = Modifier, + listHeader: String, + userName: String, + isMember: Boolean, + memberSize: Int, + onClick: () -> Unit, + onAddUserToList: () -> Unit, + onRemoveUser: () -> Unit, +) { + ListItem( + modifier = modifier.clickable(onClick = onClick), + headlineContent = { + Text( + text = listHeader, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + supportingContent = { + UserStatusInList(userName, isMember) + }, + leadingContent = { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Outlined.Groups, + contentDescription = stringRes(R.string.follow_set_icon_description), + modifier = Size50ModifierOffset10, + ) + DisplayParticipantNumberAndStatus( + modifier = Modifier.align(Alignment.BottomCenter), + privateMembersSize = 0, + publicMembersSize = memberSize, + ) + } + }, + trailingContent = { + UserAdditionOptions(isMember, onAddUserToList, onRemoveUser) + }, + ) +} + +@Composable +private fun UserStatusInList( + userName: String, + isMember: Boolean, +) { + Row( + modifier = HalfHalfVertPadding, + horizontalArrangement = SpacedBy5dp, + verticalAlignment = Alignment.CenterVertically, + ) { + val text = + if (isMember) { + stringRes(R.string.follow_set_public_presence_indicator, userName) + } else { + stringRes(R.string.follow_set_absence_indicator2, userName) + } + + val icon = + if (isMember) { + Icons.Outlined.Public + } else { + Icons.Outlined.RemoveCircleOutline + } + + Icon( + imageVector = icon, + contentDescription = text, + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = text, + overflow = TextOverflow.MiddleEllipsis, + maxLines = 1, + ) + } +} + +@Composable +private fun UserAdditionOptions( + isUserInList: Boolean, + onAddUserToList: () -> Unit, + onRemoveUser: () -> Unit, +) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + IconButton( + onClick = { + if (isUserInList) { + onRemoveUser() + } else { + onAddUserToList() + } + }, + modifier = + Modifier + .background( + color = + if (isUserInList) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.primary + }, + shape = RoundedCornerShape(percent = 80), + ), + ) { + if (isUserInList) { + Icon( + imageVector = Icons.Filled.PersonRemove, + contentDescription = stringRes(R.string.remove_user_from_the_list), + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + } else { + Icon( + imageVector = Icons.Filled.PersonAdd, + contentDescription = stringRes(R.string.add_user_to_the_list), + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserItem.kt index 70acacfce..d8601793d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserItem.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -70,6 +71,7 @@ fun PeopleListAndUserMemberPreview() { privateMemberSize = 3, publicMemberSize = 2, onAddUserToList = {}, + onClick = {}, onRemoveUser = {}, ) } @@ -88,6 +90,7 @@ fun PeopleListAndUserNotMemberPreview() { privateMemberSize = 3, publicMemberSize = 2, onAddUserToList = {}, + onClick = {}, onRemoveUser = {}, ) } @@ -102,11 +105,12 @@ fun PeopleListAndUserItem( userIsPublicMember: Boolean, publicMemberSize: Int, privateMemberSize: Int, + onClick: () -> Unit, onAddUserToList: (shouldBePrivateMember: Boolean) -> Unit, onRemoveUser: () -> Unit, ) { ListItem( - modifier = modifier, + modifier = modifier.clickable(onClick = onClick), headlineContent = { Text( text = listHeader, @@ -139,7 +143,7 @@ fun PeopleListAndUserItem( } @Composable -fun UserStatusInList( +private fun UserStatusInList( userName: String, userIsPrivateMember: Boolean, userIsPublicMember: Boolean, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserView.kt deleted file mode 100644 index 09ff56919..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserView.kt +++ /dev/null @@ -1,104 +0,0 @@ -/** - * 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.lists.memberEdit - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -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.StdVertSpacer - -@Composable -fun PeopleListAndUserView( - userToAddOrRemove: User, - accountViewModel: AccountViewModel, - nav: INav, -) { - val followSetsState by accountViewModel.account.peopleLists.uiListFlow - .collectAsStateWithLifecycle() - - if (followSetsState.isEmpty()) { - Column( - Modifier - .fillMaxWidth() - .fillMaxHeight(0.5f), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text(text = stringRes(R.string.follow_set_empty_dialog_msg)) - Spacer(modifier = StdVertSpacer) - } - } else { - val userName by observeUserName(userToAddOrRemove, accountViewModel) - - LazyColumn(modifier = Modifier.fillMaxWidth()) { - itemsIndexed(followSetsState, key = { _, item -> item.identifierTag }) { _, list -> - PeopleListAndUserItem( - modifier = Modifier.fillMaxWidth(), - listHeader = list.title, - userName = userName, - userIsPrivateMember = list.privateMembers.contains(userToAddOrRemove), - userIsPublicMember = list.publicMembers.contains(userToAddOrRemove), - onRemoveUser = { - accountViewModel.runIOCatching { - accountViewModel.account.peopleLists.removeUserFromSet( - userToAddOrRemove, - isPrivate = list.privateMembers.contains(userToAddOrRemove), - list.identifierTag, - accountViewModel.account, - ) - } - }, - privateMemberSize = list.privateMembers.size, - publicMemberSize = list.publicMembers.size, - onAddUserToList = { userShouldBePrivate -> - accountViewModel.runIOCatching { - accountViewModel.account.peopleLists.addUserToSet( - userToAddOrRemove, - list.identifierTag, - userShouldBePrivate, - accountViewModel.account, - ) - } - }, - ) - HorizontalDivider(thickness = DividerThickness) - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/AddInboxRelayCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/AddInboxRelayCard.kt index a98d8401f..fcb041b89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/AddInboxRelayCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/AddInboxRelayCard.kt @@ -56,7 +56,7 @@ fun AddInboxRelayCardPreview() { ThemeComparisonColumn { AddInboxRelayCard( accountViewModel = mockAccountViewModel(), - nav = EmptyNav, + nav = EmptyNav(), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt index 2762ca840..d24f63e60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt @@ -63,7 +63,7 @@ private fun TopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) }, + onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/donations/ZapTheDevsCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/donations/ZapTheDevsCard.kt index 0f97748a2..4f7d249be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/donations/ZapTheDevsCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/donations/ZapTheDevsCard.kt @@ -109,7 +109,7 @@ fun ZapTheDevsCardPreview() { ZapTheDevsCard( releaseNote, accountViewModel, - nav = EmptyNav, + nav = EmptyNav(), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index 728ea9a9f..1643a6798 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -129,7 +129,7 @@ fun NewPublicMessageScreen( WatchAndLoadMyEmojiList(accountViewModel) BackHandler { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() } @@ -144,7 +144,7 @@ fun NewPublicMessageScreen( onCancel = { // uses the accountViewModel scope to avoid cancelling this // function when the postViewModel is released - accountViewModel.runIOCatching { + accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() } @@ -153,7 +153,7 @@ fun NewPublicMessageScreen( onPost = { // uses the accountViewModel scope to avoid cancelling this // function when the postViewModel is released - accountViewModel.runIOCatching { + accountViewModel.launchSigner { postViewModel.sendPostSync() nav.popBack() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index df9a033cc..19ba54057 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -126,7 +126,7 @@ class NewPublicMessageViewModel : draftTag.versions.collectLatest { // don't save the first if (it > 0) { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { sendDraftSync() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt index 74895503b..dbccec86d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt @@ -38,7 +38,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMapNotNull import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -77,7 +77,7 @@ private fun WatchAndRenderBadgeList( accountViewModel: AccountViewModel, nav: INav, ) { - val badgeList by observeNoteEventAndMap(note, accountViewModel) { event: BadgeProfilesEvent -> + val badgeList by observeNoteEventAndMapNotNull(note, accountViewModel) { event: BadgeProfilesEvent -> event.badgeAwardEvents().toImmutableList() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt index a24e7f683..fa660b662 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt @@ -58,7 +58,7 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { fun create() { if (hasModified) { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { saveRelayList(_relays.value.map { it.relay }) clear() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt index 8999830e2..1d2f34960 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt @@ -62,7 +62,7 @@ class Nip65RelayListViewModel : ViewModel() { fun create() { if (hasModified) { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { val writes = _homeRelays.value.map { it.relay }.toSet() val reads = _notificationRelays.value.map { it.relay }.toSet() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddInboxRelayForSearchCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddInboxRelayForSearchCard.kt index 377e3ab5e..183363a2f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddInboxRelayForSearchCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddInboxRelayForSearchCard.kt @@ -56,7 +56,7 @@ fun AddInboxRelayForSearchCardPreview() { ThemeComparisonColumn { AddInboxRelayForSearchCard( accountViewModel = mockAccountViewModel(), - nav = EmptyNav, + nav = EmptyNav(), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt index 40faa97be..746291fb2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt @@ -63,7 +63,7 @@ import java.util.Locale as JavaLocale @Composable fun UserSettingsScreenPreview() { val accountViewModel = mockAccountViewModel() - val nav = EmptyNav + val nav = EmptyNav() ThemeComparisonRow { UserSettingsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index c290cc863..9882f0106 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -578,7 +578,7 @@ private fun FullBleedNoteCompose( } else if (noteEvent is PeopleListEvent) { DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is FollowListEvent) { - DisplayFollowList(baseNote, backgroundColor, accountViewModel, nav) + DisplayFollowList(baseNote, false, accountViewModel, nav) } else if (noteEvent is AudioTrackEvent) { AudioTrackHeader(noteEvent, baseNote, ContentScale.FillWidth, accountViewModel, nav) } else if (noteEvent is AudioHeaderEvent) { @@ -1059,7 +1059,6 @@ private fun RenderLongFormHeaderForThread( private fun RenderWikiHeaderForThreadPreview() { val event = Event.fromJson("{\"id\":\"277f982a4cd3f67cc47ad9282176acabee1713848f547d6021e0c155572078e1\",\"pubkey\":\"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c\",\"created_at\":1708695717,\"kind\":30818,\"tags\":[[\"d\",\"amethyst\"],[\"a\",\"30818:f03e7c5262648e0b7823dfb49f8f17309cfec9cb14711413dcabdf3d7fc6369a:amethyst\",\"wss://relay.nostr.band\",\"fork\"],[\"e\",\"ceabc60c8022c472c727aa25ae7691885964366386ce265c47e5a78be6cb00be\",\"wss://relay.nostr.band\",\"fork\"],[\"title\",\"Amethyst\"],[\"published_at\",\"1708707133\"]],\"content\":\"An Android-only app written in Kotlin with support for over 90 event kinds. \\n\\n![](https://play-lh.googleusercontent.com/lvZlAm9dBrpHeOo7sIPKCsiKOLYLhR2b0FiOT4tyiwWO2dvsR2gDS0xk9tOOr9U-6uM=w240-h480-rw)\\n\",\"sig\":\"6748126a909a20dbdb67947a09d64e41d7140a79335a4ad675c6173d7dd5dbcab9c360dec617bd67bbbc20dfad416b15056eda2e20716cd6c425a84301a125a0\"}") as WikiNoteEvent val accountViewModel = mockAccountViewModel() - val nav = EmptyNav val editState = remember { @@ -1072,6 +1071,8 @@ private fun RenderWikiHeaderForThreadPreview() { } } + val nav = EmptyNav() + LoadNote(baseNoteHex = "277f982a4cd3f67cc47ad9282176acabee1713848f547d6021e0c155572078e1", accountViewModel = accountViewModel) { baseNote -> ThemeComparisonColumn { val bg = MaterialTheme.colorScheme.background diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt index 29efbe674..b221989c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt @@ -64,7 +64,7 @@ private fun TopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) }, + onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index 0b6c36a20..c322daa36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -51,6 +51,7 @@ import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarSize val Shapes = Shapes( @@ -82,6 +83,8 @@ val HalfVertSpacer = Modifier.height(2.dp) val MinHorzSpacer = Modifier.width(1.dp) +val HalfHorzSpacer = Modifier.width(3.dp) + val StdHorzSpacer = Modifier.width(5.dp) val StdVertSpacer = Modifier.height(5.dp) @@ -374,3 +377,5 @@ val SpacedBy10dp = Arrangement.spacedBy(Size10dp) val PopupUpEffect = RoundedCornerShape(0.dp, 0.dp, 15.dp, 15.dp) val Size50ModifierOffset10 = Modifier.size(50.dp).offset(y = (-10).dp) + +val FollowPackHeaderModifier = Modifier.fillMaxWidth().height(TopBarSize) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index 217d898ef..4ffc25515 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -273,6 +273,18 @@ val lightNewItemBubbleModifier = .clip(shape = CircleShape) .background(LightColorPalette.primary) +val darkBlackTagModifier = + Modifier + .clip(SmallestBorder) + .background(DarkColorPalette.onBackground) + .padding(horizontal = 5.dp) + +val lightBlackTagModifier = + Modifier + .clip(SmallestBorder) + .background(LightColorPalette.onBackground) + .padding(horizontal = 5.dp) + val RichTextDefaults = RichTextStyle().resolveDefaults() val MarkDownStyleOnDark = @@ -474,6 +486,10 @@ val ColorScheme.largeProfilePictureModifier: Modifier val ColorScheme.newItemBubbleModifier: Modifier get() = if (isLight) lightNewItemBubbleModifier else darkNewItemBubbleModifier +@Suppress("ModifierFactoryExtensionFunction") +val ColorScheme.blackTagModifier: Modifier + get() = if (isLight) lightBlackTagModifier else darkBlackTagModifier + val chartLightColors = VicoTheme( candlestickCartesianLayerColors = diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 1828c8a4e..2226c0bdd 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -125,6 +125,8 @@ Kanál veřejného chatu Globální kanál Kanál vyhledávání + Vyhledat a přidat uživatele + Přidat uživatele Přidat přeposílání Jméno Zobrazované jméno @@ -440,11 +442,12 @@ Seznam sledovaných Všechna sledování Všech Uživatelů Sledování + Výchozí seznam sledování Sleduje přes proxy Kolem mě Globální Seznam ztlumení - Sady sledování + Seznamy sledování Označené záložky Obecné záložky Veřejné @@ -452,7 +455,7 @@ Smíšené Zdá se, že zatím nemáte žádné sady sledování.\nKlepněte níže pro obnovení nebo použijte tlačítko přidat k vytvoření nové. Nový - Přidat autora do sady sledování + Přidat autora do seznamu sledování Přidat nebo odebrat uživatele ze seznamů, nebo vytvořit nový seznam s tímto uživatelem. Ikona pro seznam %1$s %1$s je veřejný člen @@ -466,26 +469,29 @@ člen členové Žádní členové + Prázdné %1$s není v tomto seznamu - Vaše sady sledování - Nebyly nalezeny žádné sady sledování, nebo žádné nemáte. Klepněte níže pro obnovení nebo použijte menu pro vytvoření nové. + %1$s není členem + Vaše seznamy a %1$s + Vaše seznamy + Nebyly nalezeny žádné seznamy sledování, nebo žádné nemáte. Klepněte níže pro obnovení, nebo použijte menu pro vytvoření nového. Došlo k problému při načítání: %1$s Vytvořit nový seznam Vytvořit nový seznam %1$s s uživatelem Vytvoří %1$s sadu sledování a přidá do ní %2$s. - Nový seznam %1$s - Kopírovat/Klonovat sadu sledování + Nový seznam sledování + Kopírovat/Klonovat seznam sledování Upravit popis Tento seznam nemá žádný popis Aktuální popis: - Níže můžete nastavit vlastní název/popisek pro tuto klonovanou sadu. - Název sady - (Původní název sady) - Popis sady (volitelné) - (Původní popis sady) - Vytvořit sadu - Kopírovat/Klonovat sadu - Přejmenovat sadu + Níže nastavte nový název/popisek pro klonovaný seznam. + Název seznamu + Název nového seznamu + Popis seznamu (nepovinné) + Popis nového seznamu + Vytvořit seznam + Kopírovat/Klonovat seznam + Přejmenovat seznam Upravit Přejmenováváte z na.. @@ -1082,6 +1088,8 @@ Pro otevření a stažení souboru nejsou nainstalovány žádné torrent aplikace. Událost nemá dostatek informací pro vytvoření magnet odkazu Moje seznamy/sady + Moje seznamy + Uživatelé Vyberte seznam pro filtrování kanálu Odhlásit se na zámek zařízení Soukromá zpráva @@ -1104,4 +1112,13 @@ Odeslat Tato zpráva zmizí za %1$d dní Vybrat podepisovatele + Již v seznamu + Soukromí členové + Veřejní členové + Soukromí členové (%1$s) + Veřejní členové (%1$s) + Přidat uživatele do seznamu + Odebrat uživatele ze seznamu + Balíček Sledování + Členové diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 99e04e593..72094a578 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -125,6 +125,8 @@ Öffentlicher Chat Globale Feed Such-Feed + Benutzer suchen und hinzufügen + Benutzer hinzufügen Relay hinzufügen Name Anzeigename @@ -446,11 +448,12 @@ anz der Bedingungen ist erforderlich Folgen-Liste Alle Folgen Alle Benutzer Folgen + Standard-Folgenliste Folgt über Proxy In der Nähe Weltweit Stummliste - Folge-Sets + Follower-Listen Markierte Lesezeichen Allgemeine Lesezeichen Öffentlich @@ -458,7 +461,7 @@ anz der Bedingungen ist erforderlich Gemischt Es scheint, dass du noch keine Folge-Sets hast.\nTippe unten zum Aktualisieren oder verwende die Plus-Taste, um ein neues zu erstellen. Neu - Autor zum Folge-Set hinzufügen + Autor zur Follower-Liste hinzufügen Benutzer zu Listen hinzufügen oder entfernen, oder eine neue Liste mit diesem Benutzer erstellen. Symbol für %1$s-Liste %1$s ist ein öffentliches Mitglied @@ -472,26 +475,29 @@ anz der Bedingungen ist erforderlich Mitglied Mitglieder Keine Mitglieder + Leer %1$s ist nicht in dieser Liste - Deine Folge-Sets - Keine Folge-Sets gefunden oder du hast keine. Tippe unten zum Aktualisieren oder verwende das Menü, um eines zu erstellen. + %1$s ist kein Mitglied + Deine Listen und %1$s + Deine Listen + Keine Follower-Listen gefunden oder du hast keine. Tippe unten, um zu aktualisieren, oder verwende das Menü, um eine zu erstellen. Beim Abrufen ist ein Problem aufgetreten: %1$s Neue Liste erstellen Neue %1$s-Liste mit Benutzer erstellen Erstellt ein %1$s-Folge-Set und fügt %2$s hinzu. - Neue %1$s-Liste - Follow-Set kopieren/klonen + Neue Follower-Liste + Follower-Liste kopieren/klonen Beschreibung bearbeiten Diese Liste hat keine Beschreibung Aktuelle Beschreibung: - Unten kannst du einen eigenen Namen/Beschreibung für diesen Klon festlegen. - Set-Name - (Original-Set-Name) - Set-Beschreibung (optional) - (Original-Set-Beschreibung) - Set erstellen - Set kopieren/klonen - Set umbenennen + Lege unten einen neuen Namen/Beschreibung für die geklonte Liste fest. + Listenname + Neuer Listenname + Listenbeschreibung (optional) + Beschreibung der neuen Liste + Liste erstellen + Liste kopieren/klonen + Liste umbenennen Bearbeiten Du benennst um von zu.. @@ -1087,6 +1093,8 @@ anz der Bedingungen ist erforderlich Keine Torrent-Apps installiert, um die Datei zu öffnen und herunterzuladen. Das Ereignis enthält nicht genügend Informationen, um einen Magnetlink zu erstellen Meine Listen/Sets + Meine Listen + Benutzer Liste zum Filtern des Feeds auswählen Beim Sperren des Geräts abmelden Private Nachricht @@ -1109,4 +1117,13 @@ anz der Bedingungen ist erforderlich Senden Diese Nachricht verschwindet in %1$d Tagen Signierer auswählen + Bereits in der Liste + Private Mitglieder + Öffentliche Mitglieder + Private Mitglieder (%1$s) + Öffentliche Mitglieder (%1$s) + Benutzer zur Liste hinzufügen + Benutzer aus der Liste entfernen + Folge Paket + Mitglieder diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 72b71b730..d8d019210 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -125,6 +125,8 @@ सार्वजनिक चर्चा सूचनावली वैश्विक सूचनावली खोज सूचनावली + ढूँढें तथा प्रयेक्ता जोडें + प्रयोक्ता जोडें पुनःप्रसारक जोडें नाम प्रदर्शन नाम @@ -310,7 +312,7 @@ केवल पढ सकते हैं, निजी कुंचिका नहीं पीछे चलें चयन करें - जालसंचारक जोड बाँटें + जालसंचारक योजक बाँटें बाँटें लेखक विभेदक टीका विभेदक @@ -442,6 +444,7 @@ अनुचरण सूची सभी अनुचरित प्रयोक्ता के सभी अनुगामी + मूल अनुचरण सूची प्रतिनिधि द्वारा अनुचरित मेरे आसपास वैश्विक @@ -470,7 +473,10 @@ सदस्य सदस्य सूची कोई सदस्य नहीं + रिक्त %1$s इस सूची में नहीं है + %1$s एक सदस्य नहीं + आपके सूचियाँ तथा %1$s आपके अनुगम्य सूचियाँ कोई अनुगम्य सूचियाँ प्राप्त नहीं। अथवा आपका कोई अनुगम्य सूचियाँ हैं नहीं। नवीकरण के लिए नीचे दबाएँ अथवा विकल्पसूची द्वारा एक नया बनाएँ। लाने में अपक्रम : %1$s @@ -1087,6 +1093,8 @@ कोई उग्रप्रवाह क्रमक स्थापित नहीं अभिलेख खोलने तथा अवरोहण करने के लिए। अभिलेखविभेदक युक्त जालनिर्देशक बनाने के लिए पर्याप्त जानकारी नहीं है घटना में मेरे सूचियाँ / समुच्चय + मेरे सूचियाँ + प्रयोक्ता सूची सूचनावली छानने के लिए सूची चुनें यन्त्र ताला लगने पर निर्गमनांकन करें निजी सन्देश @@ -1109,4 +1117,13 @@ भेजें यह सन्देश %1$d दिनों में अदृश्य हो जाएगा हस्ताक्षरकर्ता चुनें + पहले से ही सूची में + निजी सदस्य + सार्वजनिक सदस्य + निजी सदस्य (%1$s) + सार्वजनिक सदस्य (%1$s) + प्रयोक्ता को सूची में जोडें + सूची से प्रयोक्ता हटाएँ + अनुचरण पोटली + सदस्य सूची diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 8f65532f8..b951de13d 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -125,6 +125,8 @@ Nyilvános csevegési hírfolyam Globális hírfolyam Hírfolyam keresése + Felhasználó keresése és hozzáadása + Felhasználó ozzáadása Egy átjátszó hozzáadása Név Megjelenítendő név @@ -470,7 +472,10 @@ tag tagok Nincsenek tagok + Üres A(z) %1$s nincs a listában + %1$s nem tag + Saját listák és %1$s Saját követési gyüjtemények Nem találhatók követési gyüjtemények, vagy nincs követési gyüjteménye. Érintse meg az alábbi gombot a frissítéshez vagy használja a menüt egy gyüjtemény létrehozásához. Probléma történt a következő lekérdezésekor: %1$s @@ -693,7 +698,7 @@ Új funkció Ennek az üzemmódnak az aktiválásához az Amethystnek NIP-17 üzenetet kell küldenie (GiftWrapped, Sealed Direct és csoport-üzenetek). A NIP-17 új, és a legtöbb kliens még nem implementálta. Győződjön meg arról is, hogy a kedvezményezett kompatibilis klienst használ. Aktiválás - Nyílvános + Nyilvános Új nyilvános vagy privát csoport Átjátszó Privát @@ -1087,6 +1092,8 @@ A fájl megnyitásához és letöltéséhez nincsenek torrent-alkalmazások telepítve. Az esemény nem tartalmaz elegendő információt a mágneshivatkozás létrehozásához Saját lista/gyüjtemény + Saját listák + Felhasználók Lista kiválasztása a hírfolyam szűréséhez Kijelentkeztetés az eszköz zárolása esetén Privát üzenet @@ -1109,4 +1116,8 @@ Küldés Ez az üzenet %1$d nap múlva eltűnik Aláíró kiválasztása + Már rajta van a listán + Privát tagok + Nyilvános tagok + Felhasználó hozzáadása a listához diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 967436d49..7f69d130e 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -125,6 +125,8 @@ Kanał Czatu Publicznego Kanał Ogólny Przeszukaj kanał + Szukaj i dodaj użytkownika + Dodaj użytkownika Dodaj Transmiter Imię Nazwa użytkownika @@ -467,7 +469,10 @@ uczestnik uczestnicy Brak uczestników + Pusty %1$s nie jest na liście + %1$s nie jest uczestnikiem + Twoje listy oraz %1$s Twój zbiór obserwowanych Nie znaleziono zbiorów obserwowanych lub nie masz żadnych zbiorów obserwowanych. Dotknij poniżej, aby odświeżyć lub użyj menu, aby go utworzyć. Podczas pobierania wystąpił błąd: %1$s @@ -1084,6 +1089,8 @@ Brak zainstalowanych aplikacji torrent do otwarcia i pobrania pliku. Zdarzenie nie ma wystarczającej ilości informacji, aby zbudować link magnetyczny Moje Listy/Zbiory + Moje listy + Użytkownicy Wybierz listę, aby filtrować aktualności Wyloguj się przy blokowaniu urządzenia Wiadomość prywatna @@ -1106,4 +1113,9 @@ Prześlij Ta wiadomość zniknie za %1$d dni Wybierz Sygnatariusza + Już jest na liście + Prywatni uczestnicy + Uczestnicy publiczni + Dodaj użytkownika do listy + Usuń użytkownika z listy diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index f5c8e6cc9..54c8b4fec 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -125,6 +125,8 @@ Feed Chat Público Feed Global Fonte de pesquisa + Pesquisar e adicionar usuário + Adicionar um usuário Adicionar um Relay Nome Nome de Exibição @@ -440,11 +442,12 @@ Lista de seguidores Seguindo Seguindo do Usuários + Lista Padrão de Seguir Segue via proxy Perto de mim Global Lista Silenciada - Conjuntos de Seguimento + Listas de seguidores Favoritos com etiqueta Favoritos gerais Público @@ -452,7 +455,7 @@ Misto Parece que você ainda não tem conjuntos de seguimento.\nToque abaixo para atualizar ou use o botão de adicionar para criar um novo. Novo - Adicionar autor ao conjunto de seguimento + Adicionar autor à lista de seguidores Adicionar ou remover usuário de listas, ou criar uma nova lista com este usuário. Ícone da lista %1$s %1$s é um membro público @@ -466,26 +469,29 @@ membro membros Nenhum membro + Vazio %1$s não está nesta lista - Seus conjuntos de seguimento - Nenhum conjunto de seguimento foi encontrado ou você não possui nenhum. Toque abaixo para atualizar ou use o menu para criar um. + %1$s não é membro + Suas listas e %1$s + Suas listas + Nenhuma lista de seguidores encontrada, ou você não possui nenhuma. Toque abaixo para atualizar ou use o menu para criar uma nova. Houve um problema ao buscar: %1$s Criar nova lista Criar nova lista %1$s com usuário Cria um conjunto de seguimento %1$s e adiciona %2$s a ele. - Nova lista %1$s - Copiar/Clonar conjunto de seguidores + Nova lista de seguidores + Copiar/Clonar lista de seguidores Modificar descrição Esta lista não tem descrição Descrição atual: - Você pode definir um nome/descrição personalizada para este conjunto clonado abaixo. - Nome do conjunto - (Nome original do conjunto) - Descrição do conjunto (opcional) - (Descrição original do conjunto) - Criar conjunto - Copiar/Clonar conjunto - Renomear conjunto + Defina abaixo um novo nome/descrição para a lista clonada. + Nome da lista + Novo nome da lista + Descrição da lista (opcional) + Descrição da nova lista + Criar lista + Copiar/Clonar lista + Renomear lista Modificar Você está renomeando de para.. @@ -1082,6 +1088,8 @@ Nenhum aplicativo torrent instalado para abrir e baixar o arquivo. O evento não tem informações suficientes para criar um link magnético Minhas listas/conjuntos + Minhas listas + Usuários Selecione uma lista para filtrar o feed Terminar sessão no bloqueio do dispositivo Mensagem Privada @@ -1104,4 +1112,13 @@ Enviar Esta mensagem desaparecerá em %1$d dias Selecionar assinador + Já está na lista + Membros privados + Membros públicos + Membros privados (%1$s) + Membros públicos (%1$s) + Adicionar usuário à lista + Remover usuário da lista + Pacote Seguir + Membros diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index fc189ad67..b88552788 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -90,12 +90,12 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem " Sledi" " Sledilcev" "%1$s Sledi" - "%1$s Sledi" + "%1$s Sledilcev" Profil Varnostni filtri Odjavi se Pokaži več - Lightning faktura + Lightning račun Plačaj Lightning napitnine Sporočilo prejemniku @@ -131,11 +131,13 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Bajti Napake Število napak pri povezovanju v tej seji - Domači vir novic - Vir privatnih sporočil - Javni vir novic - Globalni vir - Išči vir + Domače vsebine + Vsebine privatnih sporočil + Vsebina javnih novic + Globalne vsebine + Išči vsebine + Išči in dodaj uporabnika + Dodaj Uporabnika Dodaj rele Ime Prikazano ime @@ -176,13 +178,13 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Blokirani uporabniki Nove objave Pogovori - Vir + Vsebine Moderatorska vrsta Zapiski Pogovori Vaše Galerija - "Sledi" + "sledilcev" "Reportaže" "%1$s Prijave" Več možnosti @@ -197,7 +199,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Kopiraj javni ključ (NPub) v odložišče Pošlji direktno sporočilo Uredi uporabnikove metapodatke - Sledi + Sledim Sledi nazaj Deblokiraj Kopiraj uporabnikov ID @@ -225,12 +227,12 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Že imam Nostr račun? Ustvari nov račun Zgeneriraj nov ključ - Vir se nalaga + Nalagam vsebine Račun se nalaga "Napaka pri nalaganju odgovorov: " Poskusi ponovno Ni še obvestil. - Vir je prazen. + Ni vsebin. Osveži ustvarjeno z opisom @@ -453,17 +455,61 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Spisek komu sledimo Vse, čemur sledimo Vsi sledeni sledenih + Privzeti seznam sledenih Sledi preko posrednika V moji okolici Globalno Spisek utišanih + Seznam sledenih Označeni zaznamki Splošni zaznamki Javno Zasebno Mešano + Videti je, da še nimate setov sledenih. + \nTapnite spodaj za osvežitev ali dodajte nov seznam. + + Nov + Dodaj avtorja v seznam sledenih Dodaj ali odstrani uprabnika iz seznama, ali pa ustvari nov seznam s tem uporabnikom. + Ikona za set sledenih + %1$s je javni član + %1$s je zasebni član + Dodaj kot javni člana + Dodaj kot zasebni član + Javni člani + Zasebni člani + Javni profili + Zasebni profili + član + člani + ni članov + Prazno + %1$s ni v tem seznamu + %1$s ni član + Tvoj seznam ter %1$s + Tvoji seznami + Ni bilo najdenih seznamov sledenih ali pa nimate nobenega. Tapnite spodaj za osvežitev ali pa uporabite meni da dodate novega. + Pri pridobivanju podatkov je prišlo do napake: %1$s Ustvari nov seznam + Nov seznam s %1$s člani + Ustvari nov set sledenih, ter doda %1$s kot %2$s člana. + Nov seznam sledenih + Kopiraj/Kloniraj seznam sledenih + Spremeni opis + Ta seznam nima opisa + Trenutni opis: + Nastavi novo ime/opis za kloniran seznam spodaj + Ime seznama + Novo ime seznama + Opis seznama (neobvezno) + Nov opis seznama + Ustvari seznam + Kopiraj/kloniraj seznam + Preimenuj seznam + Uredi + Preimenuješ iz + v.. Prevzeta vrata so 9050 ## Poveži se preko Tor omrežja z Orbot aplikacijo \n\n1. Namesti [Orbot aplikacijo](https://play.google.com/store/apps/details?id=org.torproject.android) @@ -598,9 +644,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Odjava bo izbrisala vse vaše lokalne informacije. Poskrbite, da imate varnostno kopijo svojih zasebnih ključev, da se izognete izgubi računa. Ali želite nadaljevati? Spremljana vsebina Releji - Sledi tropu + Paketi sledenih Branje - Algoritemski viri + Algoritmi vsebin Tržnica Prenos v živo Skupnosti @@ -680,6 +726,17 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Prilepi iz odložišča Napačen NIP-47 URI URI %1$s ni veljaven NIP-47 vpis URI. + Poveži z novo NWC denarnico + Ne najdem denarnice + Amethyst ni našel nobene denarnice, ki podpira Nostr Wallet Connect (NWC). +\n\nKaj lahko storiš: +\n\t1. Preverite podporo za NWC: Ponovno\t preverite, ali vaša priljubljena denarnica podpira Nostr Wallet Connect. +\n\t2. Ustvari povezavo: Odprite svojo denarnico in ustvarite novo NWC povezavo. +\n\t3. Povežite z Amethyst: Vaša denarnica vam bo ponavadi ponudila tri možnosti: +\n\t\t3.1 Samodejna povezava: Gumb za samodejno odprtje in povezavo z Amethyst. Če je na voljo, kliknite ta gumb, odprite Amethyst in na novem zaslonu pritisnite shrani. +\n\t\tRočna povezava: Povezovalni URI, ki ga lahko kopirate, nato pa na tem zaslonu kliknite ikono za lepljenje, da ga neposredno vnesete. +\n\t\tQR koda: Denarnica ustvari QR-kodo, ki jo skenirajte z gumbom za QR na tem zaslonu. + Za vmesnik aplikacije Temna, svetla ali sistemska tema Samodejno naloži slike in GIF-e @@ -792,7 +849,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Po namestitvi izberite aplikacijo, ki jo želite uporabljati, v nastavitvah. Sporočilo od %1$s - Nit + Niz objav Pošlji prodajalcu sporočilo Živjo %1$s, je to še na voljo? Živjo, je to še na voljo? @@ -852,6 +909,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Visoka Nestisnjeno Uporabi H.265/HEVC Codec + Boljša kakovost pri manjših velikostih datotek, vendar predvajanja H.265 ne podpirajo vse naprave. Uredi osnutek Vpiši se z QR kodo Pot @@ -882,7 +940,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Izbirnik seznama relejev Anketa Onemogoči anketo - Bitcoin faktura + Bitcoin račun Prekliči Bitcoin fakturo Prekliči prodajo Zapraiser @@ -911,6 +969,16 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ustvarjanje seznama relejev, posebej zasnovanega za iskanje in označevanje uporabnikov, bo izboljšalo te rezultate. Vnesite 1–3 releje za iskanje vsebine ali označevanje uporabnikov. Prepričajte se, da vaši izbrani releji podpirajo NIP-50 Dobre možnosti so\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Izhodni releji + Nastavi svoje javne izhodne releje za objave + Ustvarjanje seznama relejev, namensko zasnovanih za prejem vaše vsebine, je ključnega pomena za vašo izkušnjo z Nostrom in edini način, da vas lahko najdejo vaši sledilci. + Vstavite med 1–3 releje, ki sprejemajo vaše objave. Prepričajte se, da ne zahtevajo plačila, če ne plačujete za shranjevanje vaših zapiskov na te releje + Dobre opcije so:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social + Vhodni rele + Nastavite javne vhodne releje za prejem obvestil + Ustvarjanje seznama relejev, namensko zasnovanega za prejem obvestil, je ključnega pomena za vašo izkušnjo v Nostru. + Vstavite med 1 in 3 releje, ki za vas prejemajo obvestila. Poskrbite, da ta niso plačljiva ali uporabljajo WoT (mreža zaupanja), sicer boste prejemali obvestila le od plačnikov ali dolgoletnih Nostr uporabnikov. + dobre opcije so::\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social Naloži ZS Nastavitve relejev Javni za odhajajočo pošto/domači releji @@ -939,7 +1007,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Releji katerim zaupaš in zanje ne rabiš Tor povezave Proxy releji Proxy releji - Agregatorji, ki jih mora aplikacija uporabljati za prenos vašega vira vsebin, na primer filter.nostr.wine. To nadomesti model odhodne pošte (outbox model) in bo prisililo aplikacijo k povezovanju prek relejev iz vašega seznama. + Agregatorji, ki jih mora aplikacija uporabljati za prenos vaših vsebin, na primer filter.nostr.wine. To nadomesti model odhodne pošte (outbox model) in bo prisililo aplikacijo k povezovanju prek relejev iz vašega seznama. Oddajni releji Oddajni releji Releji specializirani za posredovanje vaših zapiskov na vse ostale releje kot npr. sendit.nosflare.com. Amethyst bo dodal ta rele k vsem vašim novim dogodkom. @@ -1035,7 +1103,11 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Prenesi Datoteke ni bilo mogoče odpreti Ni nameščenih torent aplikacij za odpiranje in prenos datoteke. - Izberite seznam za filtriranje vira + Dogodek nima dovolj podatkov za sestavo magnetne povezave. + Moji seznami/seti + Moji seznami + Uporabniki + Izberite seznam za filtriranje vsebin Odjava ob zaklepu naprave Zasebno sporočilo Javno sporočilo @@ -1044,17 +1116,26 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Deli sliko… Deljenje slike ni uspelo, prosimo poskusite znova… Išči hashtag: #%1$s - Ne prevajaj iz - Jeziki, ki so prikazani tukaj, ne bodo prevedeni. Izberite jezik, da ga odstranite in ga znova prevedete. + Ne prevedi iz + Tu prikazani jeziki ne bodo prevedeni. Izberite jezik, da ga odstranite in ga znova prevedete. Premor Predvajaj Odpri spustni meni Možnost %1$s od %2$s - Filter vira, %1$s izbran - Filter vira, %1$s + Filter vsebin, %1$s izbran + Filter vsebin, %1$s Najdeno je poročilo o napaki Želite poslati poročilo o napaki Amethyst-u preko zasebnega sporočila? Vaši osebni podatki NE bojo posredovani. Pošlji To sporočilo bo izginilo čez %1$d dni Izberi podpisnika + Že na seznamu + Zasebni člani + Javni člani + Zasebni člani (%1$s) + Javni člani (%1$s) + Dodaj uporabnika na seznam + Odstrani uporabnika iz seznama + Paket sledenih + Člani diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index e326e921c..d67a72f87 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -125,6 +125,8 @@ Publik Chat Flöde Globalt Flöde Sök i Flödet + Sök och lägg till användare + Lägg till en användare Lägg till Relä Namn Visningsnamn @@ -440,11 +442,12 @@ Följ lista Alla följare Alla Användare Följare + Standard Följ-Lista Följer via proxy Runt mig Global Tyst listan - Följ-set + Följelistor Märkta bokmärken Allmänna bokmärken Offentlig @@ -452,7 +455,7 @@ Blandad Det verkar som att du inte har några följ-set ännu.\nTryck nedan för att uppdatera eller använd plusknappen för att skapa ett nytt. Ny - Lägg till författare i följ-set + Lägg till författare i följelista Lägg till eller ta bort användare från listor, eller skapa en ny lista med denna användare. Ikon för %1$s-lista %1$s är en offentlig medlem @@ -466,26 +469,29 @@ medlem medlemmar Inga medlemmar + Tom %1$s finns inte i denna lista - Dina följ-set - Inga följ-set hittades, eller så har du inga. Tryck nedan för att uppdatera eller använd menyn för att skapa ett. + %1$s är inte medlem + Dina listor och %1$s + Dina listor + Inga följelistor hittades, eller så har du inga. Tryck nedan för att uppdatera eller använd menyn för att skapa en ny. Ett problem uppstod vid hämtning: %1$s Skapa ny lista Skapa ny %1$s-lista med användare Skapar ett %1$s-följ-set och lägger till %2$s i det. - Ny %1$s-lista - Kopiera/Klona följlista + Ny följelista + Kopiera/Klona följelista Ändra beskrivning Den här listan har ingen beskrivning Nuvarande beskrivning: - Du kan ange ett anpassat namn/beskrivning för denna klonlista nedan. - Set-namn - (Ursprungligt listnamn) - Set-beskrivning (valfritt) - (Ursprunglig beskrivning) - Skapa set + Ange ett nytt namn/beskrivning för den klonade listan nedan. + Listnamn + Nytt listnamn + Listbeskrivning (valfritt) + Beskrivning av ny lista + Skapa lista Kopiera/Klona lista - Byt namn på set + Byt namn på lista Ändra Du byter namn från till.. @@ -1081,6 +1087,8 @@ Inga torrent-appar installerade för att öppna och ladda ner filen. Händelsen har inte tillräcklig information för att skapa en magnetlänk Mina listor/set + Mina listor + Användare Välj en lista för att filtrera flödet Logga ut när enheten låses Privat meddelande @@ -1103,4 +1111,13 @@ Skicka Detta meddelande försvinner om %1$d dagar Välj signatör + Redan i listan + Privata medlemmar + Offentliga medlemmar + Privata medlemmar (%1$s) + Offentliga medlemmar (%1$s) + Lägg till användare i listan + Ta bort användare från listan + Följpaket + Medlemmar diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 548230370..bf71c04c4 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -125,6 +125,8 @@ 公开聊天 全球 搜索 + 搜索并添加用户 + 添加用户 添加中继器 名称 显示名称 @@ -470,7 +472,10 @@ 成员 成员 无成员 + 此列表中没有 %1$s + %1$s 不是成员 + 您的列表和 %1$s 您的关注集 未找到关注集,或者你还没有任何关注集。轻按下方刷新,或使用按钮新建。 获取时出了问题: %1$s @@ -1087,6 +1092,8 @@ 没有用于打开和下载文件的 Torrent 客户端 事件没有足够信息来构建磁力链 我的列表/集合 + 我的列表 + 用户 选择一个用于过滤订阅源的列表 当设备锁定时注销 私信 @@ -1109,4 +1116,8 @@ 发送它 此消息将在 %1$d 天内消失 选择签名者 + 已经在列表中 + 私密成员 + 公开成员 + 添加用户到列表 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index aee6b7f40..86da1355d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -506,12 +506,14 @@ Follow List All Follows All User Follows + Default Follow List Follows via Proxy Around Me Global Mute List Follow Lists + These are follow lists designed for your own usage. You can follow users privately or publicly Labeled Bookmarks General Bookmarks Public @@ -547,6 +549,7 @@ New list with %1$s membership Creates a new follow set, and adds %1$s as a %2$s member. New Follow List + New Follow Pack Copy/Clone Follow List Modify description This list doesn\'t have a description @@ -559,6 +562,7 @@ Create list Copy/Clone list Rename list + Edit List Modify You are renaming from to.. @@ -740,6 +744,7 @@ Relays Follow Packs + These are lists of users that you recommend to other people. Only public users are allowed. Reads Feed Algorithms Marketplace @@ -1346,6 +1351,28 @@ Already in the list Private Members Public Members + Private Members (%1$s) + Public Members (%1$s) Add user to the list - Add user to the list + Remove user from the list + Follow Pack + Members + + Follow List Metadata + Follow lists metadata can be seen by anyone on Nostr. Only your private members are encrypted. + + Follow Pack Metadata + Follow pack metadata can be seen by anyone on Nostr and is frequently published in many websites as startup kits for new users. + + Edit Follow Pack + Pack name + New Pack name + Pack description + New Pack description + + Broadcast List + Broadcast Pack + + Delete List + Delete Pack diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt index 478da0243..10a0be4a8 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.tasks.await import okhttp3.OkHttpClient +import kotlin.coroutines.cancellation.CancellationException object PushNotificationUtils { var lastToken: String? = null @@ -44,6 +45,7 @@ object PushNotificationUtils { registerToken(token, accounts, okHttpClient) } catch (e: Exception) { + if (e is CancellationException) throw e Log.e("PushNotificationUtils", "Failed to get Firebase token", e) } } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.kt index cbc23ae12..0152e00e6 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.kt @@ -22,9 +22,11 @@ package com.vitorpamplona.quartz.nip01Core.core import android.os.Parcel import android.os.Parcelable +import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes +@Stable actual data class Address actual constructor( actual val kind: Kind, actual val pubKeyHex: HexKey, diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/JsonMapperNip55.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/JsonMapperNip55.kt index eedede150..f75cc16d0 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/JsonMapperNip55.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/JsonMapperNip55.kt @@ -27,6 +27,7 @@ import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.databind.node.ArrayNode import com.fasterxml.jackson.databind.node.ObjectNode import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.nip01Core.jackson.InliningTagArrayPrettyPrinter import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResultJsonDeserializer @@ -50,9 +51,9 @@ object JsonMapperNip55 { .addSerializer(Permission::class.java, PermissionSerializer()), ) - inline fun fromJsonTo(json: String): T = defaultMapper.readValue(json, T::class.java) + inline fun fromJsonTo(json: String): T = defaultMapper.readValue(json) - inline fun fromJsonTo(json: InputStream): T = defaultMapper.readValue(json, T::class.java) + inline fun fromJsonTo(json: InputStream): T = defaultMapper.readValue(json) fun toJson(event: ArrayNode): String = defaultMapper.writeValueAsString(event) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/utils/ContentResolverExt.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/utils/ContentResolverExt.kt index ccb95a789..5072d3ec1 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/utils/ContentResolverExt.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/utils/ContentResolverExt.kt @@ -26,6 +26,7 @@ import android.net.Uri import com.vitorpamplona.quartz.nip55AndroidSigner.api.IResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException fun ContentResolver.query( uri: Uri, @@ -51,6 +52,7 @@ fun ContentResolver.query( } } } catch (e: Exception) { + if (e is CancellationException) throw e Log.e("ExternalSignerLauncher", "Failed to query the Signer app in the background", e) SignerResult.RequestIncomplete.ErrorExceptionCallingContentResolver(e) } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DecryptZapResponse.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DecryptZapResponse.kt index fec2ca5ad..6848dca3c 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DecryptZapResponse.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DecryptZapResponse.kt @@ -34,7 +34,7 @@ class DecryptZapResponse { ) fun parse(intent: IntentResult): SignerResult.RequestAddressed { - if (intent.rejected) { + if (intent.rejected == true) { return SignerResult.RequestAddressed.ManuallyRejected() } val eventJson = intent.result diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DeriveKeyResponse.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DeriveKeyResponse.kt index c1353a1e6..4254c061f 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DeriveKeyResponse.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DeriveKeyResponse.kt @@ -33,7 +33,7 @@ class DeriveKeyResponse { ) fun parse(intent: IntentResult): SignerResult.RequestAddressed { - if (intent.rejected) { + if (intent.rejected == true) { return SignerResult.RequestAddressed.ManuallyRejected() } val newPrivateKey = intent.result diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip04DecryptResponse.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip04DecryptResponse.kt index ee94be069..79f9e923b 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip04DecryptResponse.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip04DecryptResponse.kt @@ -32,7 +32,7 @@ class Nip04DecryptResponse { ) fun parse(intent: IntentResult): SignerResult.RequestAddressed { - if (intent.rejected) { + if (intent.rejected == true) { return SignerResult.RequestAddressed.ManuallyRejected() } val plaintext = intent.result diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip04EncryptResponse.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip04EncryptResponse.kt index 874f782e0..5057be7ce 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip04EncryptResponse.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip04EncryptResponse.kt @@ -32,7 +32,7 @@ class Nip04EncryptResponse { ) fun parse(intent: IntentResult): SignerResult.RequestAddressed { - if (intent.rejected) { + if (intent.rejected == true) { return SignerResult.RequestAddressed.ManuallyRejected() } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip44DecryptResponse.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip44DecryptResponse.kt index 2f609f0e9..c360ad2e3 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip44DecryptResponse.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip44DecryptResponse.kt @@ -32,7 +32,7 @@ class Nip44DecryptResponse { ) fun parse(intent: IntentResult): SignerResult.RequestAddressed { - if (intent.rejected) { + if (intent.rejected == true) { return SignerResult.RequestAddressed.ManuallyRejected() } val plaintext = intent.result diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip44EncryptResponse.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip44EncryptResponse.kt index fc5615442..9c2e42172 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip44EncryptResponse.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip44EncryptResponse.kt @@ -32,7 +32,7 @@ class Nip44EncryptResponse { ) fun parse(intent: IntentResult): SignerResult.RequestAddressed { - if (intent.rejected) { + if (intent.rejected == true) { return SignerResult.RequestAddressed.ManuallyRejected() } val ciphertext = intent.result diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/SignResponse.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/SignResponse.kt index 74a8c8375..c9da8a175 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/SignResponse.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/SignResponse.kt @@ -39,7 +39,7 @@ class SignResponse { intent: IntentResult, unsignedEvent: Event, ): SignerResult.RequestAddressed { - if (intent.rejected) { + if (intent.rejected == true) { return SignerResult.RequestAddressed.ManuallyRejected() } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResult.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResult.kt index 86c612afa..a5d9a8930 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResult.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResult.kt @@ -31,16 +31,17 @@ data class IntentResult( val result: String? = null, val event: String? = null, val id: String? = null, - val rejected: Boolean = false, + val rejected: Boolean? = false, ) : OptimizedSerializable { fun toJson(): String = JsonMapperNip55.toJson(this) fun toIntent(): Intent { val intent = Intent() - intent.putExtra("id", id) - intent.putExtra("result", result) - intent.putExtra("event", event) - intent.putExtra("package", `package`) + if (id != null) intent.putExtra("id", id) + if (result != null) intent.putExtra("result", result) + if (event != null) intent.putExtra("event", event) + if (`package` != null) intent.putExtra("package", `package`) + if (rejected != null) intent.putExtra("rejected", rejected) return intent } @@ -51,7 +52,12 @@ data class IntentResult( result = data.getStringExtra("result"), event = data.getStringExtra("event"), `package` = data.getStringExtra("package"), - rejected = data.extras?.containsKey("rejected") == true, + rejected = + if (data.extras?.containsKey("rejected") == true) { + data.getBooleanExtra("rejected", false) + } else { + null + }, ) fun fromJson(json: String): IntentResult = JsonMapperNip55.fromJsonTo(json) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResultJsonDeserializer.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResultJsonDeserializer.kt index 0631da0d9..785c09fd4 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResultJsonDeserializer.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResultJsonDeserializer.kt @@ -24,6 +24,8 @@ import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import com.vitorpamplona.quartz.utils.asBooleanOrNull +import com.vitorpamplona.quartz.utils.asTextOrNull class IntentResultJsonDeserializer : StdDeserializer(IntentResult::class.java) { override fun deserialize( @@ -31,12 +33,13 @@ class IntentResultJsonDeserializer : StdDeserializer(IntentResult: ctxt: DeserializationContext, ): IntentResult { val jsonObject: JsonNode = jp.codec.readTree(jp) + return IntentResult( - `package` = jsonObject.get("package")?.asText()?.intern(), - result = jsonObject.get("result")?.asText(), - event = jsonObject.get("event")?.asText(), - id = jsonObject.get("id")?.asText()?.intern(), - rejected = jsonObject.get("rejected")?.asBoolean() ?: false, + `package` = jsonObject.get("package")?.asTextOrNull()?.intern(), + result = jsonObject.get("result")?.asTextOrNull(), + event = jsonObject.get("event")?.asTextOrNull(), + id = jsonObject.get("id")?.asTextOrNull()?.intern(), + rejected = jsonObject.get("rejected")?.asBooleanOrNull(), ) } } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResultJsonSerializer.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResultJsonSerializer.kt index d8e5f9226..7fa8cb123 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResultJsonSerializer.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResultJsonSerializer.kt @@ -35,7 +35,7 @@ class IntentResultJsonSerializer : StdSerializer(IntentResult::cla result.result?.let { gen.writeStringField("result", it) } result.event?.let { gen.writeStringField("event", it) } result.id?.let { gen.writeStringField("id", it) } - result.rejected.let { gen.writeBooleanField("rejected", it) } + result.rejected?.let { gen.writeBooleanField("rejected", it) } gen.writeEndObject() } } diff --git a/quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/foreground/intents/results/IntentResultSerializerTest.kt b/quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/foreground/intents/results/IntentResultSerializerTest.kt new file mode 100644 index 000000000..e98e49596 --- /dev/null +++ b/quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/foreground/intents/results/IntentResultSerializerTest.kt @@ -0,0 +1,90 @@ +/** + * 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.nip55AndroidSigner.foreground.intents.results + +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class IntentResultSerializerTest { + val example = + """ +[ + { + "package": null, + "signature": "336590c3f90dc6b3e709090f48b63cc82db01f4a53702d3a9802c7647d43b8a946964fd8178a5439a655e6a2a9af1143573c64d5828f526b2bf9b24bfbde61dd", + "result": "336590c3f90dc6b3e709090f48b63cc82db01f4a53702d3a9802c7647d43b8a946964fd8178a5439a655e6a2a9af1143573c64d5828f526b2bf9b24bfbde61dd", + "rejected": null, + "id": "z6AkVNy2jAH4vcUcUaYIZHrOhi6oWROj" + }, + { + "package": null, + "signature": "2560b238cabffd3c4b02b3f9f131fceb96c388f68e4a60382b833b74871ef5474baa409e6278768a47330e8b1be041a3980c242fb05014d451603e86e6240683", + "result": "2560b238cabffd3c4b02b3f9f131fceb96c388f68e4a60382b833b74871ef5474baa409e6278768a47330e8b1be041a3980c242fb05014d451603e86e6240683", + "rejected": null, + "id": "ZQGMSmJbSbqCBRxc7elKskWHEPldIJ2j" + }, + { + "package": null, + "signature": "3577ce8638367ed0569e1953bc8379ec252d66903edf6e607bbc0c081039b3ca8bee82df1956981e598a7ea172d84671d66b052a138ce41c5f43f96aed05f536", + "result": "3577ce8638367ed0569e1953bc8379ec252d66903edf6e607bbc0c081039b3ca8bee82df1956981e598a7ea172d84671d66b052a138ce41c5f43f96aed05f536", + "rejected": null, + "id": "yuIGh0hwUN5vN3mYTJoMAP1kE7EjedEu" + }, + { + "package": null, + "signature": "2c39187a337083f473e0b3b31867efbc5399e5d64e8085295eb6f5cd94149f7a2563be90a38ca26519b2e4943137d08ebd743ae7c65d11715c9ff8b99d676c57", + "result": "2c39187a337083f473e0b3b31867efbc5399e5d64e8085295eb6f5cd94149f7a2563be90a38ca26519b2e4943137d08ebd743ae7c65d11715c9ff8b99d676c57", + "rejected": null, + "id": "iAGHGc9OKEkSze36Zqhtep0cGi26oWZp" + } +] + """.trimIndent() + + @Test + fun testDeserializer() { + val results = IntentResult.fromJsonArray(example) + + println("${results.get(0).javaClass.simpleName}") + assertEquals(4, results.size) + + assertEquals("z6AkVNy2jAH4vcUcUaYIZHrOhi6oWROj", results[0].id) + assertNull(results[0].`package`) + assertEquals("336590c3f90dc6b3e709090f48b63cc82db01f4a53702d3a9802c7647d43b8a946964fd8178a5439a655e6a2a9af1143573c64d5828f526b2bf9b24bfbde61dd", results[0].result) + assertNull(results[0].rejected) + + assertEquals("ZQGMSmJbSbqCBRxc7elKskWHEPldIJ2j", results[1].id) + assertNull(results[1].`package`) + assertEquals("2560b238cabffd3c4b02b3f9f131fceb96c388f68e4a60382b833b74871ef5474baa409e6278768a47330e8b1be041a3980c242fb05014d451603e86e6240683", results[1].result) + assertNull(results[1].rejected) + + assertEquals("yuIGh0hwUN5vN3mYTJoMAP1kE7EjedEu", results[2].id) + assertNull(results[2].`package`) + assertEquals("3577ce8638367ed0569e1953bc8379ec252d66903edf6e607bbc0c081039b3ca8bee82df1956981e598a7ea172d84671d66b052a138ce41c5f43f96aed05f536", results[2].result) + assertNull(results[2].rejected) + + assertEquals("iAGHGc9OKEkSze36Zqhtep0cGi26oWZp", results[3].id) + assertNull(results[3].`package`) + assertEquals("2c39187a337083f473e0b3b31867efbc5399e5d64e8085295eb6f5cd94149f7a2563be90a38ca26519b2e4943137d08ebd743ae7c65d11715c9ff8b99d676c57", results[3].result) + assertNull(results[3].rejected) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt index 1ca19fdca..e09b86f15 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt @@ -60,6 +60,12 @@ class TagArrayBuilder { return this } + fun addFirst(tag: Array): TagArrayBuilder { + if (tag.isEmpty() || tag[0].isEmpty()) return this + tagList.getOrPut(tag[0], ::mutableListOf).add(0, tag) + return this + } + fun addUnique(tag: Array): TagArrayBuilder { if (tag.isEmpty() || tag[0].isEmpty()) return this tagList[tag[0]] = mutableListOf(tag) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientStaticReqAsStateFlow.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientStaticReqAsStateFlow.kt index 65897db25..7b7e6cdc2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientStaticReqAsStateFlow.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientStaticReqAsStateFlow.kt @@ -42,22 +42,22 @@ import kotlinx.coroutines.flow.callbackFlow * - They will be ignored if they are already in the list. * - They will be added to the beginning of the list if they are new. */ -fun INostrClient.reqResultsInOrderAsFlow( +fun INostrClient.reqAsFlow( relay: String, filters: List, -) = reqResultsInOrderAsFlow(RelayUrlNormalizer.normalize(relay), filters) +) = reqAsFlow(RelayUrlNormalizer.normalize(relay), filters) -fun INostrClient.reqResultsInOrderAsFlow( +fun INostrClient.reqAsFlow( relay: String, filter: Filter, -) = reqResultsInOrderAsFlow(RelayUrlNormalizer.normalize(relay), listOf(filter)) +) = reqAsFlow(RelayUrlNormalizer.normalize(relay), listOf(filter)) -fun INostrClient.reqResultsInOrderAsFlow( +fun INostrClient.reqAsFlow( relay: NormalizedRelayUrl, filter: Filter, -) = reqResultsInOrderAsFlow(relay, listOf(filter)) +) = reqAsFlow(relay, listOf(filter)) -fun INostrClient.reqResultsInOrderAsFlow( +fun INostrClient.reqAsFlow( relay: NormalizedRelayUrl, filters: List, ): Flow> = @@ -65,7 +65,7 @@ fun INostrClient.reqResultsInOrderAsFlow( val subId = RandomInstance.randomChars(10) var hasBeenLive = false val eventIds = mutableSetOf() - val events = mutableListOf() + var currentEvents = listOf() val listener = object : IRequestListener { @@ -77,12 +77,16 @@ fun INostrClient.reqResultsInOrderAsFlow( ) { if (event.id !in eventIds) { if (hasBeenLive) { - events.add(0, event) + // faster + val list = ArrayList(1 + currentEvents.size) + list.add(event) + list.addAll(currentEvents) + currentEvents = list } else { - events.add(event) + currentEvents = currentEvents + event } eventIds.add(event.id) - trySend(events.toList()) + trySend(currentEvents) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt index e3db2dd1d..1b55f0eb0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt @@ -54,3 +54,8 @@ fun eventUpdate( createdAt: Long = TimeUtils.now(), updater: TagArrayBuilder.() -> Unit = {}, ) = EventTemplate(createdAt, base.kind, base.tags.builder(updater), base.content) + +fun T.update( + createdAt: Long = TimeUtils.now(), + updater: TagArrayBuilder.() -> Unit = {}, +) = eventUpdate(this, createdAt, updater) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt index 34dc78e70..4ab945171 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip51Lists.followList import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder @@ -70,6 +71,16 @@ class FollowListEvent( const val KIND = 39089 const val ALT = "List of people to follow" + fun createAddress( + pubKey: HexKey, + dTag: String, + ) = Address(KIND, pubKey, dTag) + + fun listFor( + pubKey: HexKey, + dTag: String, + ): String = Address.assemble(KIND, pubKey, dTag) + @OptIn(ExperimentalUuidApi::class) suspend fun create( name: String, @@ -161,7 +172,7 @@ class FollowListEvent( ) { dTag(dTag) alt(ALT) - name(name) + title(name) people(people) initializer() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/TagArrayBuilderExt.kt index 511395f54..9cab92e01 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/TagArrayBuilderExt.kt @@ -20,10 +20,32 @@ */ package com.vitorpamplona.quartz.nip51Lists.followList +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag -import com.vitorpamplona.quartz.nip51Lists.tags.NameTag +import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag +import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag +import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag -fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.description(desc: String) = addUnique(DescriptionTag.assemble(desc)) + +fun TagArrayBuilder.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl)) fun TagArrayBuilder.people(peoples: List) = addAll(peoples.map { it.toTagArray() }) + +fun TagArrayBuilder.person(person: UserTag) = add(person.toTagArray()) + +fun TagArrayBuilder.person( + pubkey: HexKey, + relayHint: NormalizedRelayUrl?, +) = add(UserTag.assemble(pubkey, relayHint)) + +fun TagArrayBuilder.personFirst( + pubkey: HexKey, + relayHint: NormalizedRelayUrl?, +) = addFirst(UserTag.assemble(pubkey, relayHint)) + +fun TagArrayBuilder.removePerson(pubkey: HexKey) = remove(UserTag.TAG_NAME, pubkey) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt index 3af970d2a..9e6aa6041 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt @@ -40,8 +40,8 @@ import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.remove -import com.vitorpamplona.quartz.nip51Lists.replaceAll import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag +import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag import com.vitorpamplona.quartz.nip51Lists.tags.NameTag import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.uuid.ExperimentalUuidApi @@ -70,6 +70,8 @@ class PeopleListEvent( fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + fun users() = tags.users() fun countMutes() = tags.count(MuteTag::isTagged) @@ -87,7 +89,17 @@ class PeopleListEvent( fun createBlockAddress(pubKey: HexKey) = Address(KIND, pubKey, BLOCK_LIST_D_TAG) - fun blockListFor(pubKeyHex: HexKey): String = "30000:$pubKeyHex:$BLOCK_LIST_D_TAG" + fun blockListFor(pubKeyHex: HexKey): String = Address.assemble(KIND, pubKeyHex, BLOCK_LIST_D_TAG) + + fun createAddress( + pubKey: HexKey, + dTag: String, + ) = Address(KIND, pubKey, dTag) + + fun listFor( + pubKey: HexKey, + dTag: String, + ): String = Address.assemble(KIND, pubKey, dTag) @OptIn(ExperimentalUuidApi::class) suspend fun create( @@ -378,69 +390,5 @@ class PeopleListEvent( signer = signer, createdAt = createdAt, ) - - suspend fun modifyListName( - earlierVersion: PeopleListEvent, - newName: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - ): PeopleListEvent { - val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() - val currentTitle = earlierVersion.tags.first { it[0] == NameTag.TAG_NAME || it[0] == TitleTag.TAG_NAME } - val newTitleTag = - if (currentTitle[0] == NameTag.TAG_NAME) { - NameTag.assemble(newName) - } else { - TitleTag.assemble(newName) - } - - return resign( - publicTags = earlierVersion.tags.replaceAll(currentTitle, newTitleTag), - privateTags = privateTags.replaceAll(currentTitle, newTitleTag), - signer = signer, - createdAt = createdAt, - ) - } - - suspend fun modifyDescription( - earlierVersion: PeopleListEvent, - newDescription: String?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - ): PeopleListEvent? { - val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() - val currentDescriptionTag = earlierVersion.tags.firstOrNull { it[0] == DescriptionTag.TAG_NAME } - val currentDescription = currentDescriptionTag?.get(1) - if (currentDescription.equals(newDescription)) { - // Do nothing - return null - } else { - if (newDescription == null || newDescription.isEmpty()) { - return resign( - publicTags = earlierVersion.tags.remove { it[0] == DescriptionTag.TAG_NAME }, - privateTags = privateTags.remove { it[0] == DescriptionTag.TAG_NAME }, - signer = signer, - createdAt = createdAt, - ) - } else { - val newDescriptionTag = DescriptionTag.assemble(newDescription) - return if (currentDescriptionTag == null) { - resign( - publicTags = earlierVersion.tags.plusElement(newDescriptionTag), - privateTags = privateTags, - signer = signer, - createdAt = createdAt, - ) - } else { - resign( - publicTags = earlierVersion.tags.replaceAll(currentDescriptionTag, newDescriptionTag), - privateTags = privateTags.replaceAll(currentDescriptionTag, newDescriptionTag), - signer = signer, - createdAt = createdAt, - ) - } - } - } - } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayBuilderExt.kt index 2c9053ab2..b3e67ad42 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayBuilderExt.kt @@ -22,8 +22,14 @@ package com.vitorpamplona.quartz.nip51Lists.peopleList import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag +import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag import com.vitorpamplona.quartz.nip51Lists.tags.NameTag fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) +fun TagArrayBuilder.description(desc: String) = addUnique(DescriptionTag.assemble(desc)) + +fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) + fun TagArrayBuilder.peoples(peoples: List) = addAll(peoples.map { it.toTagArray() }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/tags/ImageTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/tags/ImageTag.kt index 7dd334d66..3d6c79aa4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/tags/ImageTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/tags/ImageTag.kt @@ -29,6 +29,6 @@ class ImageTag { return tag[1] } - fun assemble(name: String) = arrayOf(TAG_NAME, name) + fun assemble(url: String) = arrayOf(TAG_NAME, url) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt index d6c198a64..5abc44d6e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt @@ -35,7 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.firstTaggedAddress import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.firstTaggedEvent import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nip01Core.tags.people.firstTaggedUser +import com.vitorpamplona.quartz.nip01Core.tags.people.firstTaggedUserId import com.vitorpamplona.quartz.nip01Core.tags.references.ReferenceTag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag @@ -116,7 +116,7 @@ class HighlightEvent( fun inUrl() = tags.firstNotNullOfOrNull(ReferenceTag::parse) - fun author() = firstTaggedUser() + fun author() = firstTaggedUserId() fun quote() = content diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServersEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServersEvent.kt index bbdaef7ee..aaa98ae93 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServersEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServersEvent.kt @@ -66,18 +66,15 @@ class BlossomServersEvent( suspend fun updateRelayList( earlierVersion: BlossomServersEvent, - relays: List, + servers: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), ): BlossomServersEvent { val tags = earlierVersion.tags .filter { it[0] != "server" } - .plus( - relays.map { - arrayOf("server", it) - }, - ).toTypedArray() + .plus(servers.map { arrayOf("server", it) }) + .toTypedArray() return signer.sign(createdAt, KIND, tags, earlierVersion.content) } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt index 273a1da62..7b7cbeeca 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt @@ -24,6 +24,9 @@ import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.fasterxml.jackson.databind.node.ObjectNode +import com.vitorpamplona.quartz.utils.asIntOrNull +import com.vitorpamplona.quartz.utils.asLongOrNull +import com.vitorpamplona.quartz.utils.asTextOrNull class FilterDeserializer : StdDeserializer(Filter::class.java) { override fun deserialize( @@ -43,14 +46,14 @@ class ManualFilterDeserializer { } return Filter( - ids = jsonObject.get("ids").map { it.asText() }, - authors = jsonObject.get("authors").map { it.asText() }, - kinds = jsonObject.get("kinds").map { it.asInt() }, - tags = tags.associateWith { jsonObject.get(it).map { it.asText() } }, - since = jsonObject.get("since").asLong(), - until = jsonObject.get("until").asLong(), - limit = jsonObject.get("limit").asInt(), - search = jsonObject.get("search").asText(), + ids = jsonObject.get("ids").mapNotNull { it.asTextOrNull() }, + authors = jsonObject.get("authors").mapNotNull { it.asTextOrNull() }, + kinds = jsonObject.get("kinds").mapNotNull { it.asIntOrNull() }, + tags = tags.associateWith { jsonObject.get(it).mapNotNull { it.asTextOrNull() } }, + since = jsonObject.get("since").asLongOrNull(), + until = jsonObject.get("until").asLongOrNull(), + limit = jsonObject.get("limit").asIntOrNull(), + search = jsonObject.get("search").asTextOrNull(), ) } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerResponseDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerResponseDeserializer.kt index 3cfc25a46..c1f29e679 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerResponseDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerResponseDeserializer.kt @@ -26,6 +26,7 @@ import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseGetRelays import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey @@ -43,8 +44,7 @@ class BunkerResponseDeserializer : StdDeserializer(BunkerRespons val error = jsonObject.get("error")?.asText() if (error != null) { - return com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError - .parse(id, result, error) + return BunkerResponseError.parse(id, result, error) } if (result != null) { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt index 8dccd3219..df11129c9 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt @@ -26,6 +26,7 @@ import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.Request +import com.vitorpamplona.quartz.utils.asTextOrNull class RequestDeserializer : StdDeserializer(Request::class.java) { override fun deserialize( @@ -33,7 +34,7 @@ class RequestDeserializer : StdDeserializer(Request::class.java) { ctxt: DeserializationContext, ): Request? { val jsonObject: JsonNode = jp.codec.readTree(jp) - val method = jsonObject.get("method")?.asText() + val method = jsonObject.get("method")?.asTextOrNull() if (method == "pay_invoice") { return jp.codec.treeToValue(jsonObject, PayInvoiceMethod::class.java) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt index 492558854..2be6b46fa 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt @@ -27,6 +27,7 @@ import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.utils.asTextOrNull class ResponseDeserializer : StdDeserializer(Response::class.java) { override fun deserialize( @@ -34,7 +35,7 @@ class ResponseDeserializer : StdDeserializer(Response::class.java) { ctxt: DeserializationContext, ): Response? { val jsonObject: JsonNode = jp.codec.readTree(jp) - val resultType = jsonObject.get("result_type")?.asText() + val resultType = jsonObject.get("result_type")?.asTextOrNull() if (resultType == "pay_invoice") { val result = jsonObject.get("result") diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/jackson/RumorDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/jackson/RumorDeserializer.kt index cfccb3fc8..ff5c67c19 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/jackson/RumorDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/jackson/RumorDeserializer.kt @@ -26,6 +26,9 @@ import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.vitorpamplona.quartz.nip01Core.jackson.TagArrayManualDeserializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor +import com.vitorpamplona.quartz.utils.asIntOrNull +import com.vitorpamplona.quartz.utils.asLongOrNull +import com.vitorpamplona.quartz.utils.asTextOrNull class RumorDeserializer : StdDeserializer(Rumor::class.java) { override fun deserialize( @@ -34,12 +37,12 @@ class RumorDeserializer : StdDeserializer(Rumor::class.java) { ): Rumor { val jsonObject: JsonNode = jp.codec.readTree(jp) return Rumor( - id = jsonObject.get("id")?.asText()?.intern(), - pubKey = jsonObject.get("pubkey")?.asText()?.intern(), - createdAt = jsonObject.get("created_at")?.asLong(), - kind = jsonObject.get("kind")?.asInt(), + id = jsonObject.get("id")?.asTextOrNull()?.intern(), + pubKey = jsonObject.get("pubkey")?.asTextOrNull()?.intern(), + createdAt = jsonObject.get("created_at")?.asLongOrNull(), + kind = jsonObject.get("kind")?.asIntOrNull(), tags = TagArrayManualDeserializer.fromJson(jsonObject.get("tags")), - content = jsonObject.get("content")?.asText(), + content = jsonObject.get("content")?.asTextOrNull(), ) } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/JacksonExt.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/JacksonExt.kt new file mode 100644 index 000000000..eae64ec8b --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/JacksonExt.kt @@ -0,0 +1,31 @@ +/** + * 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.utils + +import com.fasterxml.jackson.databind.JsonNode + +fun JsonNode.asBooleanOrNull() = if (!this.isNull && this.isBoolean) this.booleanValue() else null + +fun JsonNode.asTextOrNull() = if (!this.isNull && this.isTextual) this.textValue() else null + +fun JsonNode.asLongOrNull() = if (!this.isNull && this.isNumber) this.longValue() else null + +fun JsonNode.asIntOrNull() = if (!this.isNull && this.isNumber) this.intValue() else null diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt index 958a09742..0910410d6 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.relay import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.reqResultsInOrderAsFlow +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.reqAsFlow import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope @@ -50,7 +50,7 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { val client = NostrClient(socketBuilder, appScope) val flow = - client.reqResultsInOrderAsFlow( + client.reqAsFlow( relay = "wss://relay.damus.io", filter = Filter( @@ -88,7 +88,7 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { val client = NostrClient(socketBuilder, appScope) val flow = - client.reqResultsInOrderAsFlow( + client.reqAsFlow( relay = "wss://relay.damus.io", filter = Filter(