From d17f39e85bfd1508f10dd7578d02903594d8bd53 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 6 Nov 2025 16:18:08 -0500 Subject: [PATCH 01/43] Fixes naming cropping the menu icon --- .../ui/screen/loggedIn/lists/list/PeopleListItem.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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..bc671c5b6 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,7 +81,7 @@ 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", emptySet(), emptySet(), @@ -173,7 +173,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, From 26a6b5d2192e6206c352fd0d0bbe2653664c11b6 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 6 Nov 2025 16:19:43 -0500 Subject: [PATCH 02/43] - Inverts public and private tabs - adds number of users in the tab title --- .../lists/display/PeopleListScreen.kt | 72 ++++++++++++------- amethyst/src/main/res/values/strings.xml | 2 + 2 files changed, 49 insertions(+), 25 deletions(-) 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/PeopleListScreen.kt index 8e3522c9a..7cfbad800 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/PeopleListScreen.kt @@ -151,24 +151,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 -> @@ -189,6 +172,45 @@ fun PeopleListScreen( } } +@Composable +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 fun TitleAndDescription(viewModel: PeopleListViewModel) { val selectedSetState = viewModel.selectedList.collectAsStateWithLifecycle() @@ -278,11 +300,11 @@ private fun RenderAddUserFieldAndSuggestions( ShowUserSuggestions( userSuggestions = 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) + viewModel.addUserToSet(user, pagerState.currentPage == 1) } userName = userName.copy( @@ -291,7 +313,7 @@ private fun RenderAddUserFieldAndSuggestions( }, onDelete = { user -> accountViewModel.runIOCatching { - viewModel.removeUserFromSet(user, pagerState.currentPage == 0) + viewModel.removeUserFromSet(user, pagerState.currentPage == 1) } userName = userName.copy( @@ -317,9 +339,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 +350,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, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index aee6b7f40..758fee0d2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1346,6 +1346,8 @@ 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 From 6ccec0681a352757e12082e6a19de0f194346db3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 7 Nov 2025 17:23:53 -0500 Subject: [PATCH 03/43] Marks address as stable --- .../kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.kt | 2 ++ 1 file changed, 2 insertions(+) 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, From c328a3aefef44e6784131d8d48c50504e6c9b552 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 7 Nov 2025 17:26:20 -0500 Subject: [PATCH 04/43] Refactors event observer name to a more precise one --- .../service/relayClient/reqCommand/event/EventObservers.kt | 2 +- .../amethyst/ui/note/UpdateReactionTypeDialog.kt | 4 ++-- .../note/creators/emojiSuggestions/WatchAndLoadMyEmojiList.kt | 4 ++-- .../ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) 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..cf04ed4b2 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, 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/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/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() } From a996065c0532e3c6b976851b66c08db6458f8358 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 7 Nov 2025 17:27:41 -0500 Subject: [PATCH 05/43] Creates new observer for events with null option --- .../reqCommand/event/EventObservers.kt | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) 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 cf04ed4b2..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 @@ -130,6 +130,32 @@ fun observeNoteEventAndMapNotNull( 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( From 948180157f051c7a7f13d65a27125ef263475ab5 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 7 Nov 2025 17:33:09 -0500 Subject: [PATCH 06/43] pushes exception upward if triggered --- .../amethyst/service/notifications/PushNotificationUtils.kt | 2 ++ .../api/background/utils/ContentResolverExt.kt | 2 ++ 2 files changed, 4 insertions(+) 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/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) } From ea83dc3fd7b12669dfd0acef27f90c967baf2ae7 Mon Sep 17 00:00:00 2001 From: Max Blake <157151994+maxblake2015@users.noreply.github.com> Date: Sat, 8 Nov 2025 12:22:45 +0000 Subject: [PATCH 07/43] Update strings.xml a small logical correction --- amethyst/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index aee6b7f40..c11cfdf02 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1347,5 +1347,5 @@ Private Members Public Members Add user to the list - Add user to the list + Remove user from the list From 93e80dda83f36b78b5498fe215f0abb9ef5c36c2 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 10 Nov 2025 12:54:37 +0000 Subject: [PATCH 08/43] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-hi-rIN/strings.xml | 13 ++++++++++++- amethyst/src/main/res/values-hu-rHU/strings.xml | 13 ++++++++++++- amethyst/src/main/res/values-pl-rPL/strings.xml | 10 ++++++++++ amethyst/src/main/res/values-zh-rCN/strings.xml | 11 +++++++++++ 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 72b71b730..12179e761 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 @@ केवल पढ सकते हैं, निजी कुंचिका नहीं पीछे चलें चयन करें - जालसंचारक जोड बाँटें + जालसंचारक योजक बाँटें बाँटें लेखक विभेदक टीका विभेदक @@ -470,7 +472,10 @@ सदस्य सदस्य सूची कोई सदस्य नहीं + रिक्त %1$s इस सूची में नहीं है + %1$s एक सदस्य नहीं + आपके सूचियाँ तथा %1$s आपके अनुगम्य सूचियाँ कोई अनुगम्य सूचियाँ प्राप्त नहीं। अथवा आपका कोई अनुगम्य सूचियाँ हैं नहीं। नवीकरण के लिए नीचे दबाएँ अथवा विकल्पसूची द्वारा एक नया बनाएँ। लाने में अपक्रम : %1$s @@ -1087,6 +1092,8 @@ कोई उग्रप्रवाह क्रमक स्थापित नहीं अभिलेख खोलने तथा अवरोहण करने के लिए। अभिलेखविभेदक युक्त जालनिर्देशक बनाने के लिए पर्याप्त जानकारी नहीं है घटना में मेरे सूचियाँ / समुच्चय + मेरे सूचियाँ + प्रयोक्ता सूची सूचनावली छानने के लिए सूची चुनें यन्त्र ताला लगने पर निर्गमनांकन करें निजी सन्देश @@ -1109,4 +1116,8 @@ भेजें यह सन्देश %1$d दिनों में अदृश्य हो जाएगा हस्ताक्षरकर्ता चुनें + पहले से ही सूची में + निजी सदस्य + सार्वजनिक सदस्य + प्रयोक्ता को सूची में जोडें 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..2cfcf520d 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,9 @@ uczestnik uczestnicy Brak uczestników + Pusty %1$s nie jest na liście + %1$s nie jest uczestnikiem 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 +1088,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 +1112,8 @@ 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 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 天内消失 选择签名者 + 已经在列表中 + 私密成员 + 公开成员 + 添加用户到列表 From 011aebfc276ff5451799281603241f8677e38572 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 10 Nov 2025 11:17:04 -0500 Subject: [PATCH 09/43] Moving preview EmptyNav to a class to avoid Nav auto-imports in production code. --- .../vitorpamplona/amethyst/model/HashtagIcon.kt | 2 +- .../amethyst/ui/components/RichTextViewer.kt | 8 ++++---- .../components/markdown/RenderContentAsMarkdown.kt | 14 +++++++------- .../ui/components/toasts/multiline/ErrorList.kt | 2 +- .../multiline/MultiUserErrorMessageDialog.kt | 2 +- .../amethyst/ui/navigation/navs/EmptyNav.kt | 2 +- .../vitorpamplona/amethyst/ui/note/BlankNote.kt | 7 ++----- .../com/vitorpamplona/amethyst/ui/note/PollNote.kt | 4 ++-- .../amethyst/ui/note/ZapNoteCompose.kt | 5 ++--- .../ui/note/elements/AddInboxRelayForDMCard.kt | 2 +- .../amethyst/ui/note/types/LiveActivity.kt | 2 +- .../amethyst/ui/note/types/Torrent.kt | 2 +- .../amethyst/ui/note/types/TorrentComment.kt | 2 +- .../chats/feed/types/RenderCreateChannelNote.kt | 2 +- .../privateDM/send/PrivateMessageEditFieldRow.kt | 2 +- .../ephemChat/metadata/NewEphemeralChatScreen.kt | 2 +- .../metadata/ChannelMetadataScreen.kt | 2 +- .../discover/nip51FollowSets/FollowSetCard.kt | 2 +- .../ui/screen/loggedIn/home/AddOutboxRelayCard.kt | 2 +- .../loggedIn/lists/display/PeopleListScreen.kt | 2 +- .../loggedIn/notifications/AddInboxRelayCard.kt | 2 +- .../notifications/donations/ZapTheDevsCard.kt | 2 +- .../loggedIn/search/AddInboxRelayForSearchCard.kt | 2 +- .../screen/loggedIn/settings/UserSettingsScreen.kt | 2 +- .../screen/loggedIn/threadview/ThreadFeedView.kt | 5 +++-- 25 files changed, 39 insertions(+), 42 deletions(-) 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/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index ce7e7ea98..0c3a0138a 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) 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/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/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/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/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/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/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/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/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/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index f959c987a..f129856d4 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(), ) } } 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..5dc8898dd 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 @@ -111,7 +111,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/discover/nip51FollowSets/FollowSetCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/FollowSetCard.kt index dda0079c9..29bc8d3f9 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 @@ -108,7 +108,7 @@ fun RenderFollowSetThumb( @Composable fun RenderFollowSetThumbPreview() { val accountViewModel = mockAccountViewModel() - val nav = EmptyNav + val nav = EmptyNav() ThemeComparisonColumn( toPreview = { 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/lists/display/PeopleListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListScreen.kt index 7cfbad800..3397c139e 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/PeopleListScreen.kt @@ -378,7 +378,7 @@ fun FollowSetListViewPreview() { memberList = persistentListOf(user1, user2, user3), onDeleteUser = { user -> }, accountViewModel = accountViewModel, - nav = EmptyNav, + nav = EmptyNav(), ) Spacer(HalfVertSpacer) 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/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/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..7f390e3d7 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, 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 From 58128d9ad0e418314f495694fe437422c657b8dc Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 10 Nov 2025 11:17:22 -0500 Subject: [PATCH 10/43] Making Channel metadata view model stable for performance --- .../nip28PublicChat/metadata/ChannelMetadataViewModel.kt | 2 ++ 1 file changed, 2 insertions(+) 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..c16ff125e 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 @@ -54,6 +55,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlin.coroutines.cancellation.CancellationException +@Stable class ChannelMetadataViewModel : ViewModel() { private var account: Account? = null private var originalChannel: PublicChatChannel? = null From ffaa06fed7eea1660a5de5bebfeaef96b1d4fc53 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 10 Nov 2025 11:18:18 -0500 Subject: [PATCH 11/43] Adds an option to render a user gallery from HexKeys instead of full User objects --- .../vitorpamplona/amethyst/ui/note/Gallery.kt | 44 ++++++++++ .../amethyst/ui/note/UserProfilePicture.kt | 80 +++++++++++++++++++ 2 files changed, 124 insertions(+) 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/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, From 15e79092223f75c95cc35a282361d790fd714770 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 10 Nov 2025 18:01:16 +0100 Subject: [PATCH 12/43] updated cz, de, pt, sv --- .../src/main/res/values-cs-rCZ/strings.xml | 37 ++++++++++++------- .../src/main/res/values-de-rDE/strings.xml | 37 ++++++++++++------- .../src/main/res/values-pt-rBR/strings.xml | 36 +++++++++++------- .../src/main/res/values-sv-rSE/strings.xml | 37 ++++++++++++------- 4 files changed, 91 insertions(+), 56 deletions(-) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 1828c8a4e..959f6fca5 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -444,7 +444,6 @@ Kolem mě Globální Seznam ztlumení - Sady sledování Označené záložky Obecné záložky Veřejné @@ -452,7 +451,6 @@ 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 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 @@ -467,25 +465,13 @@ členové Žádní členové %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é. 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í 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 Upravit Přejmenováváte z na.. @@ -1104,4 +1090,27 @@ Odeslat Tato zpráva zmizí za %1$d dní Vybrat podepisovatele + + Odebrat uživatele ze seznamu + %1$s není členem + Vaše seznamy a %1$s + Přidat uživatele do seznamu + Prázdné + Vyhledat a přidat uživatele + Přidat uživatele + Seznamy sledování + Přidat autora do seznamu sledování + 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. + Nový seznam sledování + Kopírovat/Klonovat seznam sledování + 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 + diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 99e04e593..0dc316f85 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -450,7 +450,6 @@ anz der Bedingungen ist erforderlich In der Nähe Weltweit Stummliste - Folge-Sets Markierte Lesezeichen Allgemeine Lesezeichen Öffentlich @@ -458,7 +457,6 @@ 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 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 @@ -473,25 +471,13 @@ anz der Bedingungen ist erforderlich Mitglieder Keine Mitglieder %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. 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 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 Bearbeiten Du benennst um von zu.. @@ -1109,4 +1095,27 @@ anz der Bedingungen ist erforderlich Senden Diese Nachricht verschwindet in %1$d Tagen Signierer auswählen + + Benutzer aus der Liste entfernen + %1$s ist kein Mitglied + Deine Listen und %1$s + Benutzer zur Liste hinzufügen + Leer + Benutzer suchen und hinzufügen + Benutzer hinzufügen + Follower-Listen + Autor zur Follower-Liste hinzufügen + Deine Listen + Keine Follower-Listen gefunden oder du hast keine. Tippe unten, um zu aktualisieren, oder verwende das Menü, um eine zu erstellen. + Neue Follower-Liste + Follower-Liste kopieren/klonen + 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 + diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index f5c8e6cc9..6ece92451 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -444,7 +444,6 @@ Perto de mim Global Lista Silenciada - Conjuntos de Seguimento Favoritos com etiqueta Favoritos gerais Público @@ -452,7 +451,6 @@ 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 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 @@ -467,25 +465,13 @@ membros Nenhum membro %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. 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 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 Modificar Você está renomeando de para.. @@ -1104,4 +1090,26 @@ Enviar Esta mensagem desaparecerá em %1$d dias Selecionar assinador + + Remover usuário da lista + %1$s não é membro + Suas listas e %1$s + Adicionar usuário à lista + Vazio + Pesquisar e adicionar usuário + Adicionar um usuário + Listas de seguidores + Adicionar autor à lista de seguidores + 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. + Nova lista de seguidores + Copiar/Clonar lista de seguidores + 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 diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index e326e921c..8c30bcbf8 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -444,7 +444,6 @@ Runt mig Global Tyst listan - Följ-set Märkta bokmärken Allmänna bokmärken Offentlig @@ -452,7 +451,6 @@ 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 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 @@ -467,25 +465,13 @@ medlemmar Inga medlemmar %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. 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 Ä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 - Kopiera/Klona lista - Byt namn på set Ändra Du byter namn från till.. @@ -1103,4 +1089,27 @@ Skicka Detta meddelande försvinner om %1$d dagar Välj signatör + + Ta bort användare från listan + %1$s är inte medlem + Dina listor och %1$s + Lägg till användare i listan + Tom + Sök och lägg till användare + Lägg till en användare + Följelistor + Lägg till författare i följelista + 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. + Ny följelista + Kopiera/Klona följelista + 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å lista + From 5762490098e51e5a872592fe3da9add37775b1f2 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 10 Nov 2025 17:03:42 +0000 Subject: [PATCH 13/43] New Crowdin translations by GitHub Action --- .../src/main/res/values-cs-rCZ/strings.xml | 42 +++++++++---------- .../src/main/res/values-de-rDE/strings.xml | 42 +++++++++---------- .../src/main/res/values-pt-rBR/strings.xml | 41 +++++++++--------- .../src/main/res/values-sv-rSE/strings.xml | 42 +++++++++---------- 4 files changed, 80 insertions(+), 87 deletions(-) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 959f6fca5..3c80af9be 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 @@ -444,6 +446,7 @@ Kolem mě Globální Seznam ztlumení + Seznamy sledování Označené záložky Obecné záložky Veřejné @@ -451,6 +454,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 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 @@ -464,14 +468,29 @@ člen členové Žádní členové + Prázdné %1$s není v tomto seznamu + %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 sledování + Kopírovat/Klonovat seznam sledování Upravit popis Tento seznam nemá žádný popis Aktuální popis: + 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.. @@ -1090,27 +1109,6 @@ Odeslat Tato zpráva zmizí za %1$d dní Vybrat podepisovatele - - Odebrat uživatele ze seznamu - %1$s není členem - Vaše seznamy a %1$s Přidat uživatele do seznamu - Prázdné - Vyhledat a přidat uživatele - Přidat uživatele - Seznamy sledování - Přidat autora do seznamu sledování - 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. - Nový seznam sledování - Kopírovat/Klonovat seznam sledování - 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 - + Odebrat uživatele ze seznamu diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 0dc316f85..e93fd8be9 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 @@ -450,6 +452,7 @@ anz der Bedingungen ist erforderlich In der Nähe Weltweit Stummliste + Follower-Listen Markierte Lesezeichen Allgemeine Lesezeichen Öffentlich @@ -457,6 +460,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 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 @@ -470,14 +474,29 @@ anz der Bedingungen ist erforderlich Mitglied Mitglieder Keine Mitglieder + Leer %1$s ist nicht in dieser Liste + %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 Follower-Liste + Follower-Liste kopieren/klonen Beschreibung bearbeiten Diese Liste hat keine Beschreibung Aktuelle Beschreibung: + 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.. @@ -1095,27 +1114,6 @@ anz der Bedingungen ist erforderlich Senden Diese Nachricht verschwindet in %1$d Tagen Signierer auswählen - - Benutzer aus der Liste entfernen - %1$s ist kein Mitglied - Deine Listen und %1$s Benutzer zur Liste hinzufügen - Leer - Benutzer suchen und hinzufügen - Benutzer hinzufügen - Follower-Listen - Autor zur Follower-Liste hinzufügen - Deine Listen - Keine Follower-Listen gefunden oder du hast keine. Tippe unten, um zu aktualisieren, oder verwende das Menü, um eine zu erstellen. - Neue Follower-Liste - Follower-Liste kopieren/klonen - 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 - + Benutzer aus der Liste entfernen diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 6ece92451..4ac7c5e47 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 @@ -444,6 +446,7 @@ Perto de mim Global Lista Silenciada + Listas de seguidores Favoritos com etiqueta Favoritos gerais Público @@ -451,6 +454,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 à 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 @@ -464,14 +468,29 @@ membro membros Nenhum membro + Vazio %1$s não está nesta lista + %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 de seguidores + Copiar/Clonar lista de seguidores Modificar descrição Esta lista não tem descrição Descrição atual: + 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.. @@ -1090,26 +1109,6 @@ Enviar Esta mensagem desaparecerá em %1$d dias Selecionar assinador - - Remover usuário da lista - %1$s não é membro - Suas listas e %1$s Adicionar usuário à lista - Vazio - Pesquisar e adicionar usuário - Adicionar um usuário - Listas de seguidores - Adicionar autor à lista de seguidores - 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. - Nova lista de seguidores - Copiar/Clonar lista de seguidores - 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 + Remover usuário da lista diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 8c30bcbf8..ce7318797 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 @@ -444,6 +446,7 @@ Runt mig Global Tyst listan + Följelistor Märkta bokmärken Allmänna bokmärken Offentlig @@ -451,6 +454,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ö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 @@ -464,14 +468,29 @@ medlem medlemmar Inga medlemmar + Tom %1$s finns inte i denna lista + %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 följelista + Kopiera/Klona följelista Ändra beskrivning Den här listan har ingen beskrivning Nuvarande beskrivning: + 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å lista Ändra Du byter namn från till.. @@ -1089,27 +1108,6 @@ Skicka Detta meddelande försvinner om %1$d dagar Välj signatör - - Ta bort användare från listan - %1$s är inte medlem - Dina listor och %1$s Lägg till användare i listan - Tom - Sök och lägg till användare - Lägg till en användare - Följelistor - Lägg till författare i följelista - 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. - Ny följelista - Kopiera/Klona följelista - 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å lista - + Ta bort användare från listan From bd1a70039a9e75eba4ff0c6aa95ee74876ae395c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 10 Nov 2025 17:02:21 -0500 Subject: [PATCH 14/43] Creates a feed for follow packs --- .../vitorpamplona/amethyst/model/Account.kt | 4 + .../RelaySubscriptionsCoordinator.kt | 2 + .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../ui/navigation/routes/RouteMaker.kt | 130 ++++---- .../amethyst/ui/navigation/routes/Routes.kt | 22 +- .../amethyst/ui/note/NoteCompose.kt | 2 +- .../amethyst/ui/note/ReactionsRow.kt | 2 +- .../amethyst/ui/note/types/FollowList.kt | 208 +++++++----- .../amethyst/ui/note/types/PeopleList.kt | 3 +- .../ui/screen/loggedIn/AccountViewModel.kt | 5 +- .../discover/nip51FollowSets/FollowSetCard.kt | 23 +- .../followPacks/feed/FollowPackFeedScreen.kt | 313 ++++++++++++++++++ .../FollowPackFeedConversationsFeedFilter.kt | 118 +++++++ ...ollowPackFeedConversationsFeedViewModel.kt | 40 +++ .../dal/FollowPackFeedNewThreadFeedFilter.kt | 152 +++++++++ .../FollowPackFeedNewThreadFeedViewModel.kt | 40 +++ .../feed/dal/FollowPackMembersFeedFilter.kt | 54 +++ .../dal/FollowPackMembersUserFeedViewModel.kt | 40 +++ .../FollowPackFeedFilterAssembler.kt | 49 +++ ...llowPackFeedFilterAssemblerSubscription.kt | 42 +++ .../FollowPackFeedFilterSubAssembler.kt | 68 ++++ .../vitorpamplona/amethyst/ui/theme/Shape.kt | 5 + .../vitorpamplona/amethyst/ui/theme/Theme.kt | 16 + amethyst/src/main/res/values/strings.xml | 3 + 24 files changed, 1174 insertions(+), 169 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/FollowPackFeedScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackMembersFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackMembersUserFeedViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterSubAssembler.kt 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 25b8f346c..dd32e14ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1691,6 +1691,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/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/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index b35f388b0..1fe313d0f 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,6 +72,7 @@ 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 @@ -149,6 +150,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/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..4341b4644 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,8 @@ package com.vitorpamplona.amethyst.ui.navigation.routes import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavHostController import androidx.navigation.toRoute +import com.vitorpamplona.amethyst.ui.navigation.routes.Route.Room +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 @@ -96,7 +98,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, 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..9650dbc61 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, 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/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 20560510c..1ed163a43 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -933,7 +933,7 @@ fun ObserveLikeText( inner: @Composable (Int) -> Unit, ) { val reactionCount by observeNoteReactionCount(baseNote, accountViewModel) - + println("AABBCC $reactionCount ${baseNote.idHex}") inner(reactionCount) } 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..969e04b2b 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,163 @@ 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.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.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.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, 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 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(), + ) + } } - 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, + RenderFollowSetThumbEmbed( + card.value, + baseNote, + accountViewModel, + nav, ) +} - LaunchedEffect(Unit) { - accountViewModel.loadUsers(noteEvent.taggedUserIds()) { - members = it - } - } +@Composable +fun RenderFollowSetThumbEmbed( + card: FollowSetCard, + baseNote: Note, + 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) } - Box { - FlowRow(modifier = Modifier.padding(top = 5.dp)) { - toMembersShow.forEach { user -> - Column(modifier = Modifier.fillMaxWidth()) { - UserCompose( - user, - accountViewModel = accountViewModel, - nav = nav, - ) - - HorizontalDivider( - thickness = DividerThickness, - ) - } - } + GalleryUnloaded(card.users, StdPadding, accountViewModel, nav) } - if (members.size > 3 && !expanded) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background(getGradient(backgroundColor)), - ) { - ShowMoreButton { expanded = !expanded } - } + 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, + ) } } } + +@Preview +@Composable +fun RenderFollowSetThumbPreview() { + val accountViewModel = mockAccountViewModel() + + ThemeComparisonColumn { + RenderFollowSetThumbEmbed( + card = + FollowSetCard( + "Orange Pill Perú", + "https://i.postimg.cc/GtDgGY5v/5062563795762785335.jpg", + "Desc", + persistentListOf( + accountViewModel.userProfile().pubkeyHex, + accountViewModel.userProfile().pubkeyHex, + accountViewModel.userProfile().pubkeyHex, + accountViewModel.userProfile().pubkeyHex, + accountViewModel.userProfile().pubkeyHex, + ), + ), + baseNote = Note(""), + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + } +} 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/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 4f86b1000..46085c9b6 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 @@ -1076,11 +1076,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) { 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 29bc8d3f9..2d9a8f972 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 @@ -40,12 +39,11 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.R 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,10 +56,11 @@ 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 @@ -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(), ) @@ -119,11 +118,11 @@ fun RenderFollowSetThumbPreview() { "https://i.postimg.cc/GtDgGY5v/5062563795762785335.jpg", "Desc", persistentListOf( - accountViewModel.userProfile(), - accountViewModel.userProfile(), - accountViewModel.userProfile(), - accountViewModel.userProfile(), - accountViewModel.userProfile(), + accountViewModel.userProfile().pubkeyHex, + accountViewModel.userProfile().pubkeyHex, + accountViewModel.userProfile().pubkeyHex, + accountViewModel.userProfile().pubkeyHex, + accountViewModel.userProfile().pubkeyHex, ), ), baseNote = Note(""), @@ -164,7 +163,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/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..93a527567 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/FollowPackFeedScreen.kt @@ -0,0 +1,313 @@ +/** + * 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.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) +} + +@OptIn(ExperimentalMaterial3Api::class) +@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 = { + 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) } }, + ) + } + } + }, + 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, + ) + } + } + } +} + +@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/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/strings.xml b/amethyst/src/main/res/values/strings.xml index 758fee0d2..54f93434c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1350,4 +1350,7 @@ Public Members (%1$s) Add user to the list Add user to the list + + Follow Pack + Members From ddfe61eb9fff6c18e357845524df9ed2eacdd8d8 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 10 Nov 2025 17:36:07 -0500 Subject: [PATCH 15/43] adds description to the follow card ui --- .../amethyst/ui/note/NoteCompose.kt | 2 +- .../amethyst/ui/note/types/FollowList.kt | 79 +++++++++++++++---- .../discover/nip51FollowSets/FollowSetCard.kt | 65 +++++++++------ .../loggedIn/threadview/ThreadFeedView.kt | 2 +- 4 files changed, 109 insertions(+), 39 deletions(-) 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 9650dbc61..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, 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/types/FollowList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt index 969e04b2b..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 @@ -32,6 +32,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.Alignment import androidx.compose.ui.Alignment.Companion.CenterVertically @@ -42,9 +44,11 @@ import androidx.compose.ui.text.style.TextOverflow 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.service.relayClient.reqCommand.event.observeNoteEventAndMap import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +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.navigation.routes.routeFor @@ -60,6 +64,8 @@ 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.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -68,6 +74,7 @@ import kotlinx.collections.immutable.toImmutableList @Composable fun DisplayFollowList( baseNote: Note, + makeItShort: Boolean, accountViewModel: AccountViewModel, nav: INav, ) { @@ -93,6 +100,7 @@ fun DisplayFollowList( RenderFollowSetThumbEmbed( card.value, baseNote, + makeItShort, accountViewModel, nav, ) @@ -102,6 +110,7 @@ fun DisplayFollowList( fun RenderFollowSetThumbEmbed( card: FollowSetCard, baseNote: Note, + makeItShort: Boolean, accountViewModel: AccountViewModel, nav: INav, ) { @@ -152,6 +161,26 @@ fun RenderFollowSetThumbEmbed( 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, + ) + } + } } } @@ -160,22 +189,44 @@ fun RenderFollowSetThumbEmbed( 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 = - FollowSetCard( - "Orange Pill Perú", - "https://i.postimg.cc/GtDgGY5v/5062563795762785335.jpg", - "Desc", - persistentListOf( - accountViewModel.userProfile().pubkeyHex, - accountViewModel.userProfile().pubkeyHex, - accountViewModel.userProfile().pubkeyHex, - accountViewModel.userProfile().pubkeyHex, - accountViewModel.userProfile().pubkeyHex, - ), - ), - baseNote = Note(""), + card = card, + baseNote = LocalCache.getOrCreateNote(followCard.id), + makeItShort = false, accountViewModel = accountViewModel, nav = EmptyNav(), ) 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 2d9a8f972..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 @@ -38,6 +38,7 @@ 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.service.relayClient.reqCommand.event.observeNoteAndMap import com.vitorpamplona.amethyst.ui.components.MyAsyncImage @@ -63,7 +64,6 @@ 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 @@ -109,28 +109,47 @@ fun RenderFollowSetThumbPreview() { val accountViewModel = mockAccountViewModel() val nav = EmptyNav() - ThemeComparisonColumn( - toPreview = { - RenderFollowSetThumb( - card = - FollowSetCard( - "Orange Pill Perú", - "https://i.postimg.cc/GtDgGY5v/5062563795762785335.jpg", - "Desc", - persistentListOf( - accountViewModel.userProfile().pubkeyHex, - accountViewModel.userProfile().pubkeyHex, - accountViewModel.userProfile().pubkeyHex, - accountViewModel.userProfile().pubkeyHex, - accountViewModel.userProfile().pubkeyHex, - ), - ), - 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 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 7f390e3d7..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, accountViewModel, nav) + DisplayFollowList(baseNote, false, accountViewModel, nav) } else if (noteEvent is AudioTrackEvent) { AudioTrackHeader(noteEvent, baseNote, ContentScale.FillWidth, accountViewModel, nav) } else if (noteEvent is AudioHeaderEvent) { From 17fdf05847d6427edc9c0a6523deedb6fbf0d581 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 10 Nov 2025 18:00:32 -0500 Subject: [PATCH 16/43] Fixes the author of the highlight --- .../com/vitorpamplona/amethyst/ui/note/types/Highlight.kt | 4 ++-- .../vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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..1f4a0955f 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 @@ -78,7 +78,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(), @@ -124,7 +124,7 @@ fun DisplayHighlight( } val quote = - remember { + remember(highlight) { highlight.split("\n").joinToString("\n") { "> *${it.removeSuffix(" ")}*" } } 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 From db354b646f944a49d205a651508c9f4e30d21cdc Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 10 Nov 2025 18:42:17 -0500 Subject: [PATCH 17/43] Adds context to the highlights --- .../amethyst/ui/note/types/Highlight.kt | 72 ++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) 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 1f4a0955f..dfd1eda29 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( @@ -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( @@ -125,7 +179,23 @@ fun DisplayHighlight( val quote = remember(highlight) { - highlight.split("\n").joinToString("\n") { "> *${it.removeSuffix(" ")}*" } + 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( From 5f88249be696f0f439582c109bd266a8fd4d8ee2 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 10 Nov 2025 19:45:39 -0500 Subject: [PATCH 18/43] Creates a feed filter option for only kind 3 users --- .../vitorpamplona/amethyst/model/Account.kt | 4 ++ .../amethyst/model/AccountSettings.kt | 3 + .../topNavFeeds/FeedTopNavFilterState.kt | 6 ++ .../Kind3UserFollowsFeedFlow.kt | 69 +++++++++++++++++++ .../amethyst/ui/note/ReactionsRow.kt | 2 +- .../amethyst/ui/screen/TopNavFilterState.kt | 20 ++++-- .../loggedIn/discover/DiscoveryTopBar.kt | 2 +- .../ui/screen/loggedIn/home/HomeTopBar.kt | 2 +- .../notifications/NotificationTopBar.kt | 2 +- .../ui/screen/loggedIn/video/StoriesTopBar.kt | 2 +- amethyst/src/main/res/values/strings.xml | 1 + 11 files changed, 103 insertions(+), 10 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allUserFollows/Kind3UserFollowsFeedFlow.kt 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 dd32e14ca..57f15f239 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -350,6 +350,7 @@ class Account( val liveHomeFollowLists: StateFlow = FeedTopNavFilterState( feedFilterListName = settings.defaultHomeFollowList, + kind3Follows = kind3FollowList.flow, allFollows = allFollows.flow, locationFlow = geolocationFlow, followsRelays = defaultGlobalRelays.flow, @@ -365,6 +366,7 @@ class Account( val liveStoriesFollowLists: StateFlow = FeedTopNavFilterState( feedFilterListName = settings.defaultStoriesFollowList, + kind3Follows = kind3FollowList.flow, allFollows = allFollows.flow, locationFlow = geolocationFlow, followsRelays = defaultGlobalRelays.flow, @@ -380,6 +382,7 @@ class Account( val liveDiscoveryFollowLists: StateFlow = FeedTopNavFilterState( feedFilterListName = settings.defaultDiscoveryFollowList, + kind3Follows = kind3FollowList.flow, allFollows = allFollows.flow, locationFlow = geolocationFlow, followsRelays = defaultGlobalRelays.flow, @@ -395,6 +398,7 @@ class Account( val liveNotificationFollowLists: StateFlow = FeedTopNavFilterState( feedFilterListName = settings.defaultNotificationFollowList, + kind3Follows = kind3FollowList.flow, allFollows = allFollows.flow, locationFlow = geolocationFlow, followsRelays = defaultGlobalRelays.flow, 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/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/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 1ed163a43..20560510c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -933,7 +933,7 @@ fun ObserveLikeText( inner: @Composable (Int) -> Unit, ) { val reactionCount by observeNoteReactionCount(baseNote, accountViewModel) - println("AABBCC $reactionCount ${baseNote.idHex}") + inner(reactionCount) } 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/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/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/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/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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 39066f295..c8a3aff9f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -506,6 +506,7 @@ Follow List All Follows All User Follows + Default Follow List Follows via Proxy Around Me Global From d7ca9d5a82dd5bf8ea5b5398af8c7193caff9455 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 11 Nov 2025 08:53:04 +0100 Subject: [PATCH 19/43] updated cz, de, pt, sv --- amethyst/src/main/res/values-cs-rCZ/strings.xml | 6 ++++++ amethyst/src/main/res/values-de-rDE/strings.xml | 6 ++++++ amethyst/src/main/res/values-pt-rBR/strings.xml | 8 ++++++++ amethyst/src/main/res/values-sv-rSE/strings.xml | 7 +++++++ 4 files changed, 27 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 3c80af9be..2397fcde4 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1111,4 +1111,10 @@ Vybrat podepisovatele Přidat uživatele do seznamu Odebrat uživatele ze seznamu + + Moje seznamy + Uživatelé + Již v seznamu + Soukromí členové + Veřejní členové diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index e93fd8be9..9ff70435e 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1116,4 +1116,10 @@ anz der Bedingungen ist erforderlich Signierer auswählen Benutzer zur Liste hinzufügen Benutzer aus der Liste entfernen + + Meine Listen + Benutzer + Bereits in der Liste + Private Mitglieder + Öffentliche Mitglieder diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 4ac7c5e47..49aa890b1 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1111,4 +1111,12 @@ Selecionar assinador Adicionar usuário à lista Remover usuário da lista + + Minhas listas + Usuários + Já está na lista + Membros privados + Membros públicos + + diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index ce7318797..ec25ecc7d 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1110,4 +1110,11 @@ Välj signatör Lägg till användare i listan Ta bort användare från listan + + Mina listor + Användare + Redan i listan + Privata medlemmar + Offentliga medlemmar + From 255389aa3c53bdc7826523ba529df51ba0267487 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Tue, 11 Nov 2025 07:58:56 +0000 Subject: [PATCH 20/43] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-cs-rCZ/strings.xml | 9 ++++----- amethyst/src/main/res/values-de-rDE/strings.xml | 9 ++++----- amethyst/src/main/res/values-pl-rPL/strings.xml | 2 ++ amethyst/src/main/res/values-pt-rBR/strings.xml | 11 ++++------- amethyst/src/main/res/values-sv-rSE/strings.xml | 10 ++++------ 5 files changed, 18 insertions(+), 23 deletions(-) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 2397fcde4..01efa6d6f 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1087,6 +1087,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 @@ -1109,12 +1111,9 @@ Odeslat Tato zpráva zmizí za %1$d dní Vybrat podepisovatele - Přidat uživatele do seznamu - Odebrat uživatele ze seznamu - - Moje seznamy - Uživatelé Již v seznamu Soukromí členové Veřejní členové + Přidat uživatele do seznamu + Odebrat uživatele ze seznamu diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 9ff70435e..29cbdc759 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1092,6 +1092,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 @@ -1114,12 +1116,9 @@ anz der Bedingungen ist erforderlich Senden Diese Nachricht verschwindet in %1$d Tagen Signierer auswählen - Benutzer zur Liste hinzufügen - Benutzer aus der Liste entfernen - - Meine Listen - Benutzer Bereits in der Liste Private Mitglieder Öffentliche Mitglieder + Benutzer zur Liste hinzufügen + Benutzer aus der Liste entfernen diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 2cfcf520d..7f69d130e 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -472,6 +472,7 @@ 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 @@ -1116,4 +1117,5 @@ 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 49aa890b1..638019a28 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1087,6 +1087,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 @@ -1109,14 +1111,9 @@ Enviar Esta mensagem desaparecerá em %1$d dias Selecionar assinador - Adicionar usuário à lista - Remover usuário da lista - - Minhas listas - Usuários Já está na lista Membros privados Membros públicos - - + Adicionar usuário à lista + Remover usuário da lista diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index ec25ecc7d..6062cc32a 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1086,6 +1086,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 @@ -1108,13 +1110,9 @@ Skicka Detta meddelande försvinner om %1$d dagar Välj signatör - Lägg till användare i listan - Ta bort användare från listan - - Mina listor - Användare Redan i listan Privata medlemmar Offentliga medlemmar - + Lägg till användare i listan + Ta bort användare från listan From 8c3ab80e47cec0faf0367534afcdc3e69fb4af3d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 11 Nov 2025 14:00:17 -0500 Subject: [PATCH 21/43] More correctly parses null and default values from NIP-55 using Jackson --- .../nipB7Blossom/BlossomServerListState.kt | 2 +- .../service/cashu/melt/MeltProcessor.kt | 5 +- .../mediaServers/BlossomServersViewModel.kt | 4 +- .../nip55AndroidSigner/JsonMapperNip55.kt | 5 +- .../intents/responses/DecryptZapResponse.kt | 2 +- .../intents/responses/DeriveKeyResponse.kt | 2 +- .../intents/responses/Nip04DecryptResponse.kt | 2 +- .../intents/responses/Nip04EncryptResponse.kt | 2 +- .../intents/responses/Nip44DecryptResponse.kt | 2 +- .../intents/responses/Nip44EncryptResponse.kt | 2 +- .../intents/responses/SignResponse.kt | 2 +- .../intents/results/IntentResult.kt | 18 ++-- .../results/IntentResultJsonDeserializer.kt | 13 +-- .../results/IntentResultJsonSerializer.kt | 2 +- .../results/IntentResultSerializerTest.kt | 90 +++++++++++++++++++ .../nipB7Blossom/BlossomServersEvent.kt | 9 +- .../relay/filters/FilterDeserializer.kt | 19 ++-- .../jackson/BunkerResponseDeserializer.kt | 4 +- .../jackson/RequestDeserializer.kt | 3 +- .../jackson/ResponseDeserializer.kt | 3 +- .../rumors/jackson/RumorDeserializer.kt | 13 +-- .../vitorpamplona/quartz/utils/JacksonExt.kt | 31 +++++++ 22 files changed, 186 insertions(+), 49 deletions(-) create mode 100644 quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/foreground/intents/results/IntentResultSerializerTest.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/JacksonExt.kt 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/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/ui/actions/mediaServers/BlossomServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt index 4c223bf1b..cde4dc856 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 @@ -102,7 +102,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), @@ -127,5 +127,5 @@ class BlossomServersViewModel : ViewModel() { } } - private fun obtainFileServers(): List? = account.blossomServers.flow.value + private fun obtainFileServers(): List = account.blossomServers.flow.value } 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/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/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 From b14c588a34df8076a8dbf125cf67b9e37ad804cf Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 11 Nov 2025 16:01:19 -0500 Subject: [PATCH 22/43] Moves the dispatcher of the save event for server models from each of the viewmodels to the account view model since the scope gets cancelled when the screen closes. --- .../actions/mediaServers/AllMediaServersScreen.kt | 9 ++++++--- .../mediaServers/BlossomServersViewModel.kt | 15 ++++++++++----- .../actions/mediaServers/NIP96ServersViewModel.kt | 15 ++++++++++----- 3 files changed, 26 insertions(+), 13 deletions(-) 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 cde4dc856..8d1d601b9 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() } @@ -119,7 +124,7 @@ class BlossomServersViewModel : ViewModel() { fun saveFileServers() { if (isModified) { - viewModelScope.launch(Dispatchers.IO) { + accountViewModel.runIOCatching { val serverList = _fileServers.value.map { it.baseUrl } account.sendBlossomServersList(serverList) refresh() 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..c09bd7eb9 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.runIOCatching { val serverList = _fileServers.value.map { it.baseUrl } account.sendFileServersList(serverList) refresh() From a114bf9dfa699f1c08e16236bef7123230fbf168 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Tue, 11 Nov 2025 21:05:02 +0000 Subject: [PATCH 23/43] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-cs-rCZ/strings.xml | 5 +++++ amethyst/src/main/res/values-de-rDE/strings.xml | 5 +++++ amethyst/src/main/res/values-pt-rBR/strings.xml | 5 +++++ amethyst/src/main/res/values-sv-rSE/strings.xml | 5 +++++ 4 files changed, 20 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 01efa6d6f..2226c0bdd 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -442,6 +442,7 @@ 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í @@ -1114,6 +1115,10 @@ 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 29cbdc759..72094a578 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -448,6 +448,7 @@ anz der Bedingungen ist erforderlich Folgen-Liste Alle Folgen Alle Benutzer Folgen + Standard-Folgenliste Folgt über Proxy In der Nähe Weltweit @@ -1119,6 +1120,10 @@ anz der Bedingungen ist erforderlich 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-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 638019a28..54c8b4fec 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -442,6 +442,7 @@ Lista de seguidores Seguindo Seguindo do Usuários + Lista Padrão de Seguir Segue via proxy Perto de mim Global @@ -1114,6 +1115,10 @@ 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-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 6062cc32a..d67a72f87 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -442,6 +442,7 @@ Följ lista Alla följare Alla Användare Följare + Standard Följ-Lista Följer via proxy Runt mig Global @@ -1113,6 +1114,10 @@ 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 From d4b91845d5160508384a64324080efc0f82b6505 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 11 Nov 2025 16:10:49 -0500 Subject: [PATCH 24/43] rename runIOCatching to launchSigner --- .../ui/actions/NewUserMetadataViewModel.kt | 6 +- .../mediaServers/BlossomServersViewModel.kt | 2 +- .../mediaServers/NIP96ServersViewModel.kt | 2 +- .../nip22Comments/GenericCommentPostScreen.kt | 6 +- .../ui/screen/loggedIn/AccountViewModel.kt | 110 +++++++++--------- .../header/NewChatroomSubjectDialog.kt | 2 +- .../privateDM/send/ChatNewMessageViewModel.kt | 6 +- .../chats/privateDM/send/NewGroupDMScreen.kt | 6 +- .../send/PrivateMessageEditFieldRow.kt | 4 +- .../send/ChannelNewMessageViewModel.kt | 4 +- .../chats/publicChannels/send/EditFieldRow.kt | 2 +- .../nip99Classifieds/NewProductScreen.kt | 4 +- .../nip99Classifieds/NewProductViewModel.kt | 2 +- .../loggedIn/home/ShortNotePostScreen.kt | 6 +- .../loggedIn/home/ShortNotePostViewModel.kt | 2 +- .../lists/display/PeopleListScreen.kt | 10 +- .../lists/list/ListOfPeopleListsScreen.kt | 10 +- .../memberEdit/PeopleListAndUserScreen.kt | 2 +- .../lists/memberEdit/PeopleListAndUserView.kt | 4 +- .../publicMessages/NewPublicMessageScreen.kt | 6 +- .../NewPublicMessageViewModel.kt | 2 +- .../relays/common/BasicRelaySetupInfoModel.kt | 2 +- .../relays/nip65/Nip65RelayListViewModel.kt | 2 +- 23 files changed, 101 insertions(+), 101 deletions(-) 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/BlossomServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt index 8d1d601b9..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 @@ -124,7 +124,7 @@ class BlossomServersViewModel : ViewModel() { fun saveFileServers() { if (isModified) { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { val serverList = _fileServers.value.map { it.baseUrl } account.sendBlossomServersList(serverList) refresh() 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 c09bd7eb9..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 @@ -119,7 +119,7 @@ class NIP96ServersViewModel : ViewModel() { fun saveFileServers() { if (isModified) { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { val serverList = _fileServers.value.map { it.baseUrl } account.sendFileServersList(serverList) refresh() 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/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 46085c9b6..e40d72a45 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)) } @@ -1181,7 +1181,7 @@ class AccountViewModel( val hint = note.toEventHint() if (hint == null) return - runIOCatching { + launchSigner { val uploader = UploadOrchestrator() val result = uploader.upload( @@ -1274,7 +1274,7 @@ class AccountViewModel( if (isWriteable()) { val boosts = baseNote.boostedBy(userProfile()) if (boosts.isNotEmpty()) { - runIOCatching { + launchSigner { account.delete(boosts) } } else { @@ -1404,7 +1404,7 @@ class AccountViewModel( fun unwrapIfNeeded( note: Note?, onReady: (Note) -> Unit = {}, - ) = runIOCatching { + ) = launchSigner { val noteEvent = note?.event if (noteEvent != null) { val resultingNote = unwrapIfNeeded(noteEvent) @@ -1436,7 +1436,7 @@ class AccountViewModel( dvmPublicKey: User, onReady: (event: Note) -> Unit, ) { - runIOCatching { + launchSigner { account.requestDVMContentDiscovery(dvmPublicKey) { onReady(LocalCache.getOrCreateNote(it.id)) } @@ -1470,7 +1470,7 @@ class AccountViewModel( zappedNote: Note?, onSent: () -> Unit = {}, onResponse: (Response?) -> Unit, - ) = runIOCatching { + ) = launchSigner { account.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse) onSent() } @@ -1481,7 +1481,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/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..b3d43b380 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() } } @@ -393,7 +393,7 @@ class ChatNewMessageViewModel : ) { val uploadState = uploadState ?: return - accountViewModel.runIOCatching { + accountViewModel.launchSigner { if (nip17) { ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) { uploadsWaitingToBeSent += it @@ -418,7 +418,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 f129856d4..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 @@ -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/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 433a9aaf2..2b8e18167 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() } } @@ -263,7 +263,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/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index 4f5a33799..c8deefa59 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 @@ -139,7 +139,7 @@ fun NewProductScreen( WatchAndLoadMyEmojiList(accountViewModel) BackHandler { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() } @@ -154,7 +154,7 @@ 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() } 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..094886da4 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 @@ -112,7 +112,7 @@ open class NewProductViewModel : 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/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..0820ab5f6 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() } } 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/PeopleListScreen.kt index 3397c139e..b330b0d51 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/PeopleListScreen.kt @@ -237,7 +237,7 @@ private fun ListViewAndEditColumn( pagerState = pagerState, modifier = Modifier.weight(1f), onDeleteUser = { user, isPrivate -> - accountViewModel.runIOCatching { + accountViewModel.launchSigner { viewModel.removeUserFromSet(user, isPrivate) } }, @@ -303,7 +303,7 @@ private fun RenderAddUserFieldAndSuggestions( viewModel.hasUserFlow(user, pagerState.currentPage == 1) }, onSelect = { user -> - accountViewModel.runIOCatching { + accountViewModel.launchSigner { viewModel.addUserToSet(user, pagerState.currentPage == 1) } userName = @@ -312,7 +312,7 @@ private fun RenderAddUserFieldAndSuggestions( ) }, onDelete = { user -> - accountViewModel.runIOCatching { + accountViewModel.launchSigner { viewModel.removeUserFromSet(user, pagerState.currentPage == 1) } userName = @@ -563,14 +563,14 @@ fun ListActionsMenuButton( ) { ListActionsMenuButton( onBroadcastList = { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { viewModel.loadNote()?.let { updatedSetNote -> accountViewModel.broadcast(updatedSetNote) } } }, onDeleteList = { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { viewModel.deleteFollowSet() } nav.popBack() 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..bb13ab681 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 @@ -52,7 +52,7 @@ fun ListOfPeopleListsScreen( ListOfPeopleListsScreen( listFlow = accountViewModel.account.peopleLists.uiListFlow, addItem = { title: String, description: String? -> - accountViewModel.runIOCatching { + accountViewModel.launchSigner { accountViewModel.account.peopleLists.addFollowList( listName = title, listDescription = description, @@ -64,7 +64,7 @@ fun ListOfPeopleListsScreen( nav.nav(Route.PeopleListView(it)) }, renameItem = { followSet, newValue -> - accountViewModel.runIOCatching { + accountViewModel.launchSigner { accountViewModel.account.peopleLists.renameFollowList( newName = newValue, peopleList = followSet, @@ -73,7 +73,7 @@ fun ListOfPeopleListsScreen( } }, changeItemDescription = { followSet, newDescription -> - accountViewModel.runIOCatching { + accountViewModel.launchSigner { accountViewModel.account.peopleLists.modifyFollowSetDescription( newDescription = newDescription, peopleList = followSet, @@ -82,7 +82,7 @@ fun ListOfPeopleListsScreen( } }, cloneItem = { followSet, customName, customDescription -> - accountViewModel.runIOCatching { + accountViewModel.launchSigner { accountViewModel.account.peopleLists.cloneFollowSet( currentPeopleList = followSet, customCloneName = customName, @@ -92,7 +92,7 @@ fun ListOfPeopleListsScreen( } }, deleteItem = { followSet -> - accountViewModel.runIOCatching { + 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/memberEdit/PeopleListAndUserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserScreen.kt index 935f3f1e1..d86ea40f5 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/PeopleListAndUserScreen.kt @@ -135,7 +135,7 @@ private fun PeopleListAndUserFab(accountViewModel: AccountViewModel) { isOpen = false }, onCreateList = { name, description -> - accountViewModel.runIOCatching { + accountViewModel.launchSigner { accountViewModel.account.peopleLists.addFollowList( listName = name, listDescription = description, 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 index 09ff56919..b07704762 100644 --- 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 @@ -75,7 +75,7 @@ fun PeopleListAndUserView( userIsPrivateMember = list.privateMembers.contains(userToAddOrRemove), userIsPublicMember = list.publicMembers.contains(userToAddOrRemove), onRemoveUser = { - accountViewModel.runIOCatching { + accountViewModel.launchSigner { accountViewModel.account.peopleLists.removeUserFromSet( userToAddOrRemove, isPrivate = list.privateMembers.contains(userToAddOrRemove), @@ -87,7 +87,7 @@ fun PeopleListAndUserView( privateMemberSize = list.privateMembers.size, publicMemberSize = list.publicMembers.size, onAddUserToList = { userShouldBePrivate -> - accountViewModel.runIOCatching { + accountViewModel.launchSigner { accountViewModel.account.peopleLists.addUserToSet( userToAddOrRemove, list.identifierTag, 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/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() From 1cfe953a65945c98c4db78a7ca903870941f4c58 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 11 Nov 2025 16:37:15 -0500 Subject: [PATCH 25/43] Refactors the code to avoid using callback lambdas --- .../amethyst/ui/actions/EditPostView.kt | 4 +- .../amethyst/ui/actions/EditPostViewModel.kt | 39 ++++++----------- .../amethyst/ui/components/ClickableRoute.kt | 4 +- .../amethyst/ui/components/RichTextViewer.kt | 2 +- .../amethyst/ui/note/types/Highlight.kt | 4 +- .../ui/screen/loggedIn/AccountViewModel.kt | 43 ++++--------------- .../loggedIn/chats/privateDM/ChatroomView.kt | 14 +++--- .../privateDM/send/ChatNewMessageViewModel.kt | 8 +--- .../metadata/ChannelMetadataScreen.kt | 15 ++++++- .../metadata/ChannelMetadataViewModel.kt | 41 ++++++++++-------- .../send/ChannelNewMessageViewModel.kt | 8 +--- .../chats/rooms/ChatroomHeaderCompose.kt | 6 +-- .../nip99Classifieds/NewProductScreen.kt | 16 ++----- .../nip99Classifieds/NewProductViewModel.kt | 8 ++-- .../loggedIn/home/ShortNotePostViewModel.kt | 2 +- 15 files changed, 81 insertions(+), 133 deletions(-) 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/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 0c3a0138a..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 @@ -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/note/types/Highlight.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt index dfd1eda29..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 @@ -243,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/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index e40d72a45..6958fd4a3 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 @@ -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) @@ -1178,8 +1152,7 @@ class AccountViewModel( context: Context, ) { if (isWriteable()) { - val hint = note.toEventHint() - if (hint == null) return + val hint = note.toEventHint() ?: return launchSigner { val uploader = UploadOrchestrator() 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/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index b3d43b380..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 @@ -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) } } 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 5dc8898dd..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,7 +116,7 @@ fun ChannelMetadataScreen( private fun DialogContentPreview() { val accountViewModel = mockAccountViewModel() val postViewModel: ChannelMetadataViewModel = viewModel() - postViewModel.load(accountViewModel.account, null) + postViewModel.init(accountViewModel) ThemeComparisonColumn { ChannelMetadataScaffold( 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 c16ff125e..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 @@ -40,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 @@ -57,7 +58,9 @@ 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()) @@ -73,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 2b8e18167..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 @@ -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) } } 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/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index c8deefa59..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) @@ -161,16 +158,9 @@ fun NewProductScreen( 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 094886da4..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?.launchSigner { + 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/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 0820ab5f6..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 @@ -483,7 +483,7 @@ open class ShortNotePostViewModel : cancel() accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) - accountViewModel.viewModelScope.launch(Dispatchers.IO) { + accountViewModel.launchSigner { accountViewModel.account.deleteDraftIgnoreErrors(version) } } From ce073553eec1b387b2800fce37732e7a6bb5c68f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 11 Nov 2025 18:59:34 -0500 Subject: [PATCH 26/43] Makes sure the edit is Stable --- .../amethyst/ui/note/creators/draftTags/DraftTagState.kt | 2 ++ 1 file changed, 2 insertions(+) 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)) From a5207c3f0a69baa5e88de76dc51004a02ee3ec6b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 11 Nov 2025 19:00:49 -0500 Subject: [PATCH 27/43] adds recursive markAsSeen to mark the inner chat element of NIP-17 DMs for each relay that that message was received. --- .../relayClient/CacheClientConnector.kt | 39 ++++++++++++++++++- .../ui/screen/loggedIn/AccountViewModel.kt | 4 +- 2 files changed, 39 insertions(+), 4 deletions(-) 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/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 6958fd4a3..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 @@ -1309,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() From e8eb4c688c23487335c47f1f335f07e3f43a7056 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 11 Nov 2025 19:01:20 -0500 Subject: [PATCH 28/43] Modifies the flow to only output changes in the relay piece that has modified. --- .../com/vitorpamplona/amethyst/ui/note/RelayListBox.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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) } From bf803384a8ab3b7219e73d8d6221dbbdd63308d2 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 11 Nov 2025 19:01:52 -0500 Subject: [PATCH 29/43] This early decryption doesn't seem needed anymore. The event consumer in the account will take care of decrypting the new DM --- .../com/vitorpamplona/amethyst/model/Account.kt | 13 ------------- 1 file changed, 13 deletions(-) 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 57f15f239..0eb7fa0b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -175,7 +175,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 @@ -1470,18 +1469,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) } From 9eee5d9e1e08373c12eb7c962f30ba9e8028375e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 12 Nov 2025 08:27:59 -0500 Subject: [PATCH 30/43] slight performance improvement on the reqflow --- .../client/reqs/NostrClientStaticReqAsStateFlow.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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..94be9ef79 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 @@ -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) } } From 855425732927a87d538587a12a446a9039ac23f2 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 12 Nov 2025 08:29:05 -0500 Subject: [PATCH 31/43] Change reqResultsInOrderAsFlow to reqAsFlow --- .../client/reqs/NostrClientStaticReqAsStateFlow.kt | 14 +++++++------- .../relay/NostrClientSubscriptionAsFlowTest.kt | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) 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 94be9ef79..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> = 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( From b17d709696eecc064d6975b3f6c83ee5bd207aa5 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Wed, 12 Nov 2025 13:32:00 +0000 Subject: [PATCH 32/43] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-hi-rIN/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 12179e761..d8d019210 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -444,6 +444,7 @@ अनुचरण सूची सभी अनुचरित प्रयोक्ता के सभी अनुगामी + मूल अनुचरण सूची प्रतिनिधि द्वारा अनुचरित मेरे आसपास वैश्विक @@ -1119,5 +1120,10 @@ पहले से ही सूची में निजी सदस्य सार्वजनिक सदस्य + निजी सदस्य (%1$s) + सार्वजनिक सदस्य (%1$s) प्रयोक्ता को सूची में जोडें + सूची से प्रयोक्ता हटाएँ + अनुचरण पोटली + सदस्य सूची From 90330165d24edeaf397dc8b14180aff318d7f647 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 12 Nov 2025 12:33:10 -0500 Subject: [PATCH 33/43] Prepares to create follow packs --- .../nip51Lists/followList/FollowListEvent.kt | 2 +- .../followList/TagArrayBuilderExt.kt | 26 +++++++++++++++++-- .../quartz/nip51Lists/tags/ImageTag.kt | 2 +- 3 files changed, 26 insertions(+), 4 deletions(-) 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..9d849eec9 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 @@ -161,7 +161,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/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) } } From 6c642974158bc28e003236ec12c6dd4a22edf381 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 12 Nov 2025 12:33:35 -0500 Subject: [PATCH 34/43] Adds an update method for changes in current Event --- .../vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt | 6 ++++++ .../vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt | 5 +++++ 2 files changed, 11 insertions(+) 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/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) From ce49725771b2a04a992c083bf50f8953afa334d1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 12 Nov 2025 12:34:11 -0500 Subject: [PATCH 35/43] Allows customization of the Create new list dialog --- .../screen/loggedIn/lists/list/NewPeopleListCreationDialog.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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), ) } }, From bd30b526a97620a1dafe0e5d80a1f1fdd11b975b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 12 Nov 2025 12:34:49 -0500 Subject: [PATCH 36/43] Adds a @Stable marker on UserSuggestionState s --- .../ui/note/creators/userSuggestions/UserSuggestionState.kt | 2 ++ 1 file changed, 2 insertions(+) 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, From adb484bf56bb007dc923ae1458bee483ffc9df73 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 12 Nov 2025 13:18:18 -0500 Subject: [PATCH 37/43] Improves descriptions of lists and packs --- amethyst/src/main/res/values/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c8a3aff9f..f3c18eb54 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -513,6 +513,7 @@ 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 @@ -741,6 +742,7 @@ Relays Follow Packs + These are lists of users that you recommend to other people. Only public users are allowed. Reads Feed Algorithms Marketplace From 0ddbb4616befbe491df0e41c710a304710c444d7 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 12 Nov 2025 13:50:57 -0500 Subject: [PATCH 38/43] Adds creation, edits and member update for Follow Packs --- .../nip51Lists/peopleList/FollowListsState.kt | 173 +++++++++- .../amethyst/ui/navigation/AppNavigation.kt | 10 +- .../amethyst/ui/navigation/routes/Routes.kt | 7 +- .../lists/display/ListActionsMenuButton.kt | 104 ++++++ .../lists/display/ShowUserSuggestions.kt | 281 +++++++++++++++++ .../display/{ => lists}/PeopleListScreen.kt | 298 +----------------- .../{ => lists}/PeopleListViewModel.kt | 2 +- .../lists/display/packs/FollowPackScreen.kt | 297 +++++++++++++++++ .../display/packs/FollowPackViewModel.kt | 97 ++++++ .../lists/list/FollowPackViewModel.kt | 103 ++++++ .../lists/list/ListOfPeopleListFeedView.kt | 98 +++++- .../lists/list/ListOfPeopleListsScreen.kt | 118 ++----- .../loggedIn/lists/list/PeopleListItem.kt | 12 +- .../lists/list/PeopleListViewModel.kt | 103 ++++++ ...n.kt => FollowListAndPackAndUserScreen.kt} | 57 +--- .../FollowListAndPackAndUserView.kt | 209 ++++++++++++ .../lists/memberEdit/FollowPackAndUserItem.kt | 212 +++++++++++++ .../lists/memberEdit/PeopleListAndUserItem.kt | 2 +- .../lists/memberEdit/PeopleListAndUserView.kt | 104 ------ amethyst/src/main/res/values/strings.xml | 1 + 20 files changed, 1723 insertions(+), 565 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/ListActionsMenuButton.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/ShowUserSuggestions.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/{ => lists}/PeopleListScreen.kt (58%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/{ => lists}/PeopleListViewModel.kt (99%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/FollowPackViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListViewModel.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/{PeopleListAndUserScreen.kt => FollowListAndPackAndUserScreen.kt} (67%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserView.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowPackAndUserItem.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserView.kt 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..8c17c7b79 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,16 @@ 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.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.utils.flattenToSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -46,6 +56,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 +70,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 +94,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() @@ -112,6 +123,14 @@ class FollowListsState( .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Companion.Eagerly, emptyList()) + fun selectListFlow(selectedDTag: String) = + uiListFlow + .map { peopleLists -> + peopleLists.firstOrNull { list -> list.identifierTag == selectedDTag } + }.onStart { + emit(uiListFlow.value.firstOrNull { it.identifierTag == selectedDTag }) + } + fun isUserInFollowSets(user: User): Boolean = allPeopleListProfiles.value.contains(user.pubkeyHex) fun DeletionEvent.hasDeletedAnyFollowList() = deleteAddressesWithKind(FollowListEvent.Companion.KIND) || deletesAnyEventIn(followListsEventIds.value) @@ -140,4 +159,154 @@ 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?, + member: User? = null, + isPrivate: Boolean = false, + account: Account, + ) { + val newListTemplate = + FollowListEvent.build( + name = name, + people = if (!isPrivate && member != null) listOf(member.toUserTag()) else emptyList(), + dTag = UUID.randomUUID().toString(), + ) { + if (desc != null) description(desc) + } + + val newList = signer.sign(newListTemplate) + + account.sendMyPublicAndPrivateOutbox(newList) + } + + suspend fun renameFollowList( + newName: String, + followPack: PeopleList, + account: Account, + ) { + val listEvent = getPeopleList(followPack.identifierTag) + + val template = + listEvent.update { + title(newName) + } + + val newList = signer.sign(template) + + account.sendMyPublicAndPrivateOutbox(newList) + } + + suspend fun modifyFollowSetDescription( + newDescription: String, + followPack: PeopleList, + account: Account, + ) { + val listEvent = getPeopleList(followPack.identifierTag) + + val template = + listEvent.update { + description(newDescription) + } + + 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/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 1fe313d0f..c4ee7eaba 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 @@ -79,9 +79,10 @@ 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.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 @@ -127,8 +128,9 @@ 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 { NewUserMetadataScreen(nav, accountViewModel) } composable { SearchScreen(accountViewModel, nav) } 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 4341b4644..f0f31c890 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,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.navigation.routes import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavHostController import androidx.navigation.toRoute -import com.vitorpamplona.amethyst.ui.navigation.routes.Route.Room import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey @@ -56,7 +55,11 @@ 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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/ListActionsMenuButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/ListActionsMenuButton.kt new file mode 100644 index 000000000..9912ac0e0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/ListActionsMenuButton.kt @@ -0,0 +1,104 @@ +/** + * 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.border +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.components.ClickableBox +import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.StdPadding + +@Composable +fun ListActionsMenuButton( + 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() + 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() + }, + ) + } +} 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 b330b0d51..707c33f80 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,10 @@ * 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.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 @@ -35,23 +29,15 @@ 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.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 -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 +48,39 @@ 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.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.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel 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.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.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.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.DrawUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.ListActionsMenuButton +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 @@ -173,7 +138,7 @@ fun PeopleListScreen( } @Composable -fun TopAppTabs( +private fun TopAppTabs( viewModel: PeopleListViewModel, pagerState: PagerState, ) { @@ -212,7 +177,7 @@ fun TopAppTabs( } @Composable -fun TitleAndDescription(viewModel: PeopleListViewModel) { +private fun TitleAndDescription(viewModel: PeopleListViewModel) { val selectedSetState = viewModel.selectedList.collectAsStateWithLifecycle() selectedSetState.value?.let { selectedSet -> Text( @@ -255,70 +220,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 == 1) }, - onSelect = { user -> + addUserToSet = { user -> accountViewModel.launchSigner { viewModel.addUserToSet(user, pagerState.currentPage == 1) } - userName = - userName.copy( - selection = TextRange(0, userName.text.length), - ) }, - onDelete = { user -> + removeUserFromSet = { user -> accountViewModel.launchSigner { viewModel.removeUserFromSet(user, pagerState.currentPage == 1) } - userName = - userName.copy( - selection = TextRange(0, userName.text.length), - ) }, accountViewModel = accountViewModel, ) @@ -365,7 +280,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") @@ -433,130 +348,7 @@ 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, @@ -577,67 +369,3 @@ fun ListActionsMenuButton( }, ) } - -@Composable -fun ListActionsMenuButton( - 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() - 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() - }, - ) - } -} 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 99% 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..73745a9ba 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 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..f8c44b093 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt @@ -0,0 +1,297 @@ +/** + * 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.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.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults.cardElevation +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.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.note.ClearTextIcon +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.ListActionsMenuButton +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.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.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( + onBroadcastList = { + accountViewModel.launchSigner { + viewModel.loadNote()?.let { updatedSetNote -> + accountViewModel.broadcast(updatedSetNote) + } + } + }, + onDeleteList = { + accountViewModel.launchSigner { + viewModel.deleteFollowSet() + } + nav.popBack() + }, + ) +} + +@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..c7289a338 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt @@ -0,0 +1,97 @@ +/** + * 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 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 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..5fa98ee4f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/FollowPackViewModel.kt @@ -0,0 +1,103 @@ +/** + * 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.navigation.routes.Route +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 addItem( + title: String, + description: String?, + ) { + accountViewModel.launchSigner { + accountViewModel.account.followLists.addFollowList( + name = title, + desc = description, + account = accountViewModel.account, + ) + } + } + + fun openItem(dTag: String) = Route.MyFollowPackView(dTag) + + fun renameItem( + followSet: PeopleList, + newValue: String, + ) { + accountViewModel.launchSigner { + accountViewModel.account.followLists.renameFollowList( + newName = newValue, + followPack = followSet, + account = accountViewModel.account, + ) + } + } + + fun changeItemDescription( + followSet: PeopleList, + newDescription: String, + ) { + accountViewModel.launchSigner { + accountViewModel.account.followLists.modifyFollowSetDescription( + newDescription = newDescription, + followPack = followSet, + account = accountViewModel.account, + ) + } + } + + 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..448dae60d 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,44 @@ 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.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 +68,75 @@ 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, + ) + } + PeopleListFabsAndMenu( + title = R.string.follow_set_creation_dialog_title, + onAddSet = peopleListModel::addItem, + ) + } + } + 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) }, + peopleList = followSet, + onClick = { nav.nav(peopleListModel.openItem(followSet.identifierTag)) }, + onRename = { peopleListModel.renameItem(followSet, it) }, + onDescriptionChange = { newDescription -> peopleListModel.changeItemDescription(followSet, newDescription) }, + 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, + ) + } + PeopleListFabsAndMenu( + title = R.string.follow_pack_creation_dialog_title, + onAddSet = followPackModel::addItem, + ) + } + } + itemsIndexed(followPackFeedState, key = { _, item -> item.identifierTag }) { _, followSet -> + PeopleListItem( + modifier = Modifier.fillMaxSize().animateItem(), + peopleList = followSet, + onClick = { nav.nav(followPackModel.openItem(followSet.identifierTag)) }, + onRename = { followPackModel.renameItem(followSet, it) }, + onDescriptionChange = { newDescription -> followPackModel.changeItemDescription(followSet, newDescription) }, + onClone = { cloneName, cloneDescription -> followPackModel.cloneItem(followSet, cloneName, cloneDescription) }, + onDelete = { followPackModel.deleteItem(followSet) }, ) HorizontalDivider(thickness = DividerThickness) } 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 bb13ab681..7b63414e4 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,52 @@ 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.launchSigner { - accountViewModel.account.peopleLists.addFollowList( - listName = title, - listDescription = description, - account = accountViewModel.account, - ) - } - }, - openItem = { - nav.nav(Route.PeopleListView(it)) - }, - renameItem = { followSet, newValue -> - accountViewModel.launchSigner { - accountViewModel.account.peopleLists.renameFollowList( - newName = newValue, - peopleList = followSet, - account = accountViewModel.account, - ) - } - }, - changeItemDescription = { followSet, newDescription -> - accountViewModel.launchSigner { - accountViewModel.account.peopleLists.modifyFollowSetDescription( - newDescription = newDescription, - peopleList = followSet, - account = accountViewModel.account, - ) - } - }, - cloneItem = { followSet, customName, customDescription -> - accountViewModel.launchSigner { - accountViewModel.account.peopleLists.cloneFollowSet( - currentPeopleList = followSet, - customCloneName = customName, - customCloneDescription = customDescription, - account = accountViewModel.account, - ) - } - }, - deleteItem = { followSet -> - accountViewModel.launchSigner { - 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,41 +75,35 @@ 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) { +fun PeopleListFabsAndMenu( + title: Int = R.string.follow_set_creation_dialog_title, + onAddSet: (name: String, description: String?) -> Unit, +) { val isSetAdditionDialogOpen = remember { mutableStateOf(false) } - ExtendedFloatingActionButton( - text = { - Text(text = stringRes(R.string.follow_set_create_btn_label)) + OutlinedButton( + onClick = { + isSetAdditionDialogOpen.value = true }, - icon = { + ) { + Row(horizontalArrangement = SpacedBy5dp, verticalAlignment = Alignment.CenterVertically) { Icon( imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = null, ) - }, - onClick = { - isSetAdditionDialogOpen.value = true - }, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) + Text(stringRes(R.string.follow_set_create_btn_label)) + } + } if (isSetAdditionDialogOpen.value) { NewPeopleListCreationDialog( + title = title, onDismiss = { isSetAdditionDialogOpen.value = false }, 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 bc671c5b6..87e71d986 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 @@ -162,7 +162,7 @@ fun PeopleListItem( peopleList: PeopleList, onClick: () -> Unit, onRename: (String) -> Unit, - onDescriptionChange: (String?) -> Unit, + onDescriptionChange: (String) -> Unit, onClone: (customName: String?, customDescription: String?) -> Unit, onDelete: () -> Unit, ) { @@ -279,7 +279,7 @@ private fun PeopleListOptionsButton( peopleListName: String, peopleListDescription: String?, onListRename: (String) -> Unit, - onListDescriptionChange: (String?) -> Unit, + onListDescriptionChange: (String) -> Unit, onListCloneCreate: (optionalName: String?, optionalDec: String?) -> Unit, onListDelete: () -> Unit, ) { @@ -310,7 +310,7 @@ private fun ListOptionsMenu( setName: String, setDescription: String?, onListRename: (String) -> Unit, - onListDescriptionChange: (String?) -> Unit, + onListDescriptionChange: (String) -> Unit, onListClone: (optionalNewName: String?, optionalNewDesc: String?) -> Unit, onDelete: () -> Unit, onDismiss: () -> Unit, @@ -470,9 +470,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) { @@ -509,7 +509,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..fd036ed02 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListViewModel.kt @@ -0,0 +1,103 @@ +/** + * 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.navigation.routes.Route +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 addItem( + title: String, + description: String?, + ) { + accountViewModel.launchSigner { + accountViewModel.account.peopleLists.addFollowList( + listName = title, + listDescription = description, + account = accountViewModel.account, + ) + } + } + + fun openItem(dTag: String) = Route.MyPeopleListView(dTag) + + fun renameItem( + followSet: PeopleList, + newValue: String, + ) { + accountViewModel.launchSigner { + accountViewModel.account.peopleLists.renameFollowList( + newName = newValue, + peopleList = followSet, + account = accountViewModel.account, + ) + } + } + + fun changeItemDescription( + followSet: PeopleList, + newDescription: String?, + ) { + accountViewModel.launchSigner { + accountViewModel.account.peopleLists.modifyFollowSetDescription( + newDescription = newDescription, + peopleList = followSet, + account = accountViewModel.account, + ) + } + } + + 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/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 d86ea40f5..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.launchSigner { - 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..74a4ff24b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserView.kt @@ -0,0 +1,209 @@ +/** + * 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.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.PeopleListFabsAndMenu +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, + ) + } + PeopleListFabsAndMenu( + title = R.string.follow_set_creation_dialog_title, + onAddSet = { title, description -> + accountViewModel.launchSigner { + accountViewModel.account.peopleLists.addFollowList( + listName = title, + listDescription = description, + account = accountViewModel.account, + ) + } + }, + ) + } + } + 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, + 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, + ) + } + PeopleListFabsAndMenu( + title = R.string.follow_pack_creation_dialog_title, + onAddSet = { title, description -> + accountViewModel.launchSigner { + accountViewModel.account.followLists.addFollowList( + name = title, + desc = description, + account = accountViewModel.account, + ) + } + }, + ) + } + } + itemsIndexed(followPackFeedState, key = { _, item -> item.identifierTag }) { _, list -> + FollowPackAndUserItem( + modifier = Modifier.fillMaxWidth(), + listHeader = list.title, + userName = userName, + isMember = list.publicMembers.contains(userToAddOrRemove), + 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..2c8b0a7bf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowPackAndUserItem.kt @@ -0,0 +1,212 @@ +/** + * 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.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 = {}, + onRemoveUser = {}, + ) + } +} + +@Preview +@Composable +fun FollowPackAndUserNotMemberPreview() { + ThemeComparisonColumn { + FollowPackAndUserItem( + modifier = Modifier.fillMaxWidth(), + listHeader = "list title", + userName = "User", + isMember = false, + memberSize = 2, + onAddUserToList = {}, + onRemoveUser = {}, + ) + } +} + +@Composable +fun FollowPackAndUserItem( + modifier: Modifier = Modifier, + listHeader: String, + userName: String, + isMember: Boolean, + memberSize: Int, + onAddUserToList: () -> Unit, + onRemoveUser: () -> Unit, +) { + ListItem( + modifier = modifier, + 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..d66a4b1b5 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 @@ -139,7 +139,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 b07704762..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.launchSigner { - 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.launchSigner { - accountViewModel.account.peopleLists.addUserToSet( - userToAddOrRemove, - list.identifierTag, - userShouldBePrivate, - accountViewModel.account, - ) - } - }, - ) - HorizontalDivider(thickness = DividerThickness) - } - } - } -} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f3c18eb54..08bee6659 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -549,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 From 8963024a95f59b3ab68e018630758f7d94ade1cf Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 12 Nov 2025 14:18:01 -0500 Subject: [PATCH 39/43] Fixes Jump on FollowPack screens --- .../amethyst/ui/screen/UserFeedView.kt | 3 + .../followPacks/feed/FollowPackFeedScreen.kt | 186 ++++++++++-------- 2 files changed, 104 insertions(+), 85 deletions(-) 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/followPacks/feed/FollowPackFeedScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/FollowPackFeedScreen.kt index 93a527567..ecefa80a5 100644 --- 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 @@ -32,6 +32,7 @@ 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 @@ -141,7 +142,6 @@ fun PrepareViewModelsFollowPackScreen( FollowPackFeedScreen(note, newThreadFeedViewModel, conversationsFeedViewModel, membersFeedViewModel, accountViewModel, nav) } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun FollowPackFeedScreen( note: AddressableNote, @@ -162,90 +162,12 @@ fun FollowPackFeedScreen( DisappearingScaffold( isInvertedLayout = false, topBar = { - 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) } }, - ) - } - } + FollowPackFeedTopBar( + note, + pagerState, + accountViewModel, + nav, + ) }, accountViewModel = accountViewModel, ) { @@ -279,6 +201,100 @@ fun FollowPackFeedScreen( } } +@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, From b339e3a245844481268c8795d0106d48dac68786 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Wed, 12 Nov 2025 19:20:01 +0000 Subject: [PATCH 40/43] New Crowdin translations by GitHub Action --- .../src/main/res/values-sl-rSI/strings.xml | 125 +++++++++++++++--- 1 file changed, 103 insertions(+), 22 deletions(-) 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 From 29d11c8281d0a3ff4da002ad75860c0c7bbc6e90 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 12 Nov 2025 17:17:22 -0500 Subject: [PATCH 41/43] Moves the List/Pack creation Dialog to a screen and adds an Image upload option --- .../nip51Lists/peopleList/FollowListsState.kt | 61 ++-- .../model/nip51Lists/peopleList/PeopleList.kt | 1 + .../nip51Lists/peopleList/PeopleListsState.kt | 83 +++--- .../amethyst/ui/navigation/AppNavigation.kt | 5 + .../amethyst/ui/navigation/routes/Routes.kt | 17 ++ .../lists/display/ListActionsMenuButton.kt | 104 ------- .../lists/display/lists/PeopleListScreen.kt | 109 ++++++- .../display/lists/PeopleListViewModel.kt | 5 + .../lists/display/packs/FollowPackScreen.kt | 109 ++++++- .../display/packs/FollowPackViewModel.kt | 5 + .../lists/list/FollowPackViewModel.kt | 42 --- .../lists/list/ListOfPeopleListFeedView.kt | 44 +-- .../lists/list/ListOfPeopleListsScreen.kt | 27 +- .../loggedIn/lists/list/PeopleListItem.kt | 82 ++---- .../lists/list/PeopleListViewModel.kt | 42 --- .../list/metadata/FollowPackMetadataScreen.kt | 271 ++++++++++++++++++ .../metadata/FollowPackMetadataViewModel.kt | 187 ++++++++++++ .../list/metadata/PeopleListMetadataScreen.kt | 271 ++++++++++++++++++ .../metadata/PeopleListMetadataViewModel.kt | 188 ++++++++++++ .../FollowListAndPackAndUserView.kt | 29 +- amethyst/src/main/res/values/strings.xml | 19 ++ .../nip51Lists/followList/FollowListEvent.kt | 11 + .../nip51Lists/peopleList/PeopleListEvent.kt | 80 +----- .../peopleList/TagArrayBuilderExt.kt | 6 + 24 files changed, 1343 insertions(+), 455 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/ListActionsMenuButton.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt 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 8c17c7b79..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 @@ -37,11 +37,15 @@ 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 @@ -110,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()), ) @@ -123,13 +128,17 @@ class FollowListsState( .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Companion.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 isUserInFollowSets(user: User): Boolean = allPeopleListProfiles.value.contains(user.pubkeyHex) @@ -175,51 +184,43 @@ class FollowListsState( 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 = UUID.randomUUID().toString(), + dTag = dTag, ) { if (desc != null) description(desc) + if (image != null) image(image) } val newList = signer.sign(newListTemplate) account.sendMyPublicAndPrivateOutbox(newList) + return dTag } - suspend fun renameFollowList( - newName: String, - followPack: PeopleList, + suspend fun updateMetadata( + name: String?, + desc: String?, + image: String?, + peopleList: PeopleList, account: Account, ) { - val listEvent = getPeopleList(followPack.identifierTag) + val listEvent = getPeopleList(peopleList.identifierTag) val template = listEvent.update { - title(newName) - } - - val newList = signer.sign(template) - - account.sendMyPublicAndPrivateOutbox(newList) - } - - suspend fun modifyFollowSetDescription( - newDescription: String, - followPack: PeopleList, - account: Account, - ) { - val listEvent = getPeopleList(followPack.identifierTag) - - val template = - listEvent.update { - description(newDescription) + if (name != null) title(name) + if (desc != null) description(desc) + if (image != null) image(image) } val newList = signer.sign(template) 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/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index c4ee7eaba..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 @@ -82,6 +82,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen 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.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 @@ -132,6 +134,9 @@ fun AppNavigation( 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) } 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 f0f31c890..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 @@ -63,6 +63,14 @@ sealed class Route { 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() @@ -294,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/screen/loggedIn/lists/display/ListActionsMenuButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/ListActionsMenuButton.kt deleted file mode 100644 index 9912ac0e0..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/ListActionsMenuButton.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.display - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.size -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.ui.components.ClickableBox -import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon -import com.vitorpamplona.amethyst.ui.theme.ButtonBorder -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.StdPadding - -@Composable -fun ListActionsMenuButton( - 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() - 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() - }, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt index 707c33f80..ff72e8bd8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt @@ -20,6 +20,9 @@ */ 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.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer @@ -29,13 +32,17 @@ 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.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +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 @@ -55,29 +62,38 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +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.ListActionsMenuButton 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.TabRowHeight import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import kotlinx.collections.immutable.persistentListOf @@ -354,6 +370,10 @@ private fun ListActionsMenuButton( nav: INav, ) { ListActionsMenuButton( + note = viewModel::selectedNote, + onEditList = { + nav.nav { Route.PeopleListMetadataEdit(viewModel.selectedDTag.value) } + }, onBroadcastList = { accountViewModel.launchSigner { viewModel.loadNote()?.let { updatedSetNote -> @@ -369,3 +389,90 @@ private fun ListActionsMenuButton( }, ) } + +@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_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/lists/PeopleListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt index 73745a9ba..168f8484b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt @@ -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 index f8c44b093..743fdf8c1 100644 --- 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 @@ -20,6 +20,9 @@ */ 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 @@ -29,10 +32,14 @@ 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 @@ -48,29 +55,38 @@ 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.ListActionsMenuButton 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 @@ -211,6 +227,10 @@ private fun ListActionsMenuButton( nav: INav, ) { ListActionsMenuButton( + note = viewModel::selectedNote, + onEditList = { + nav.nav { Route.FollowPackMetadataEdit(viewModel.selectedDTag.value) } + }, onBroadcastList = { accountViewModel.launchSigner { viewModel.loadNote()?.let { updatedSetNote -> @@ -227,6 +247,93 @@ private fun ListActionsMenuButton( ) } +@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() { 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 index c7289a338..c51741b07 100644 --- 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 @@ -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.followList.FollowListEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -60,6 +61,10 @@ class FollowPackViewModel : ViewModel() { }.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, 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 index 5fa98ee4f..83fe0d8c6 100644 --- 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 @@ -23,7 +23,6 @@ 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.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Stable @@ -36,47 +35,6 @@ class FollowPackViewModel : ViewModel() { fun listFlow() = accountViewModel.account.followLists.uiListFlow - fun addItem( - title: String, - description: String?, - ) { - accountViewModel.launchSigner { - accountViewModel.account.followLists.addFollowList( - name = title, - desc = description, - account = accountViewModel.account, - ) - } - } - - fun openItem(dTag: String) = Route.MyFollowPackView(dTag) - - fun renameItem( - followSet: PeopleList, - newValue: String, - ) { - accountViewModel.launchSigner { - accountViewModel.account.followLists.renameFollowList( - newName = newValue, - followPack = followSet, - account = accountViewModel.account, - ) - } - } - - fun changeItemDescription( - followSet: PeopleList, - newDescription: String, - ) { - accountViewModel.launchSigner { - accountViewModel.account.followLists.modifyFollowSetDescription( - newDescription = newDescription, - followPack = followSet, - account = accountViewModel.account, - ) - } - } - fun cloneItem( followSet: PeopleList, customName: String?, 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 448dae60d..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 @@ -41,6 +41,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R 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 @@ -86,19 +87,22 @@ fun AllPeopleListFeedView( color = MaterialTheme.colorScheme.grayText, ) } - PeopleListFabsAndMenu( - title = R.string.follow_set_creation_dialog_title, - onAddSet = peopleListModel::addItem, + NewListButton( + onClick = { + nav.nav(Route.PeopleListMetadataEdit()) + }, ) } } itemsIndexed(peopleListFeedState, key = { _, item -> item.identifierTag }) { _, followSet -> PeopleListItem( - modifier = Modifier.fillMaxSize().animateItem(), + modifier = + Modifier + .fillMaxSize() + .animateItem(), peopleList = followSet, - onClick = { nav.nav(peopleListModel.openItem(followSet.identifierTag)) }, - onRename = { peopleListModel.renameItem(followSet, it) }, - onDescriptionChange = { newDescription -> peopleListModel.changeItemDescription(followSet, newDescription) }, + 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) }, ) @@ -106,7 +110,10 @@ fun AllPeopleListFeedView( } stickyHeader { Row( - modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, top = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = SpacedBy5dp, ) { @@ -122,19 +129,22 @@ fun AllPeopleListFeedView( color = MaterialTheme.colorScheme.grayText, ) } - PeopleListFabsAndMenu( - title = R.string.follow_pack_creation_dialog_title, - onAddSet = followPackModel::addItem, + NewListButton( + onClick = { + nav.nav(Route.FollowPackMetadataEdit()) + }, ) } } itemsIndexed(followPackFeedState, key = { _, item -> item.identifierTag }) { _, followSet -> PeopleListItem( - modifier = Modifier.fillMaxSize().animateItem(), + modifier = + Modifier + .fillMaxSize() + .animateItem(), peopleList = followSet, - onClick = { nav.nav(followPackModel.openItem(followSet.identifierTag)) }, - onRename = { followPackModel.renameItem(followSet, it) }, - onDescriptionChange = { newDescription -> followPackModel.changeItemDescription(followSet, newDescription) }, + 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) }, ) @@ -147,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 7b63414e4..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 @@ -31,8 +31,6 @@ 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 @@ -81,17 +79,8 @@ fun ListOfPeopleListsScreen( } @Composable -fun PeopleListFabsAndMenu( - title: Int = R.string.follow_set_creation_dialog_title, - onAddSet: (name: String, description: String?) -> Unit, -) { - val isSetAdditionDialogOpen = remember { mutableStateOf(false) } - - OutlinedButton( - onClick = { - isSetAdditionDialogOpen.value = true - }, - ) { +fun NewListButton(onClick: () -> Unit) { + OutlinedButton(onClick = onClick) { Row(horizontalArrangement = SpacedBy5dp, verticalAlignment = Alignment.CenterVertically) { Icon( imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, @@ -100,16 +89,4 @@ fun PeopleListFabsAndMenu( Text(stringRes(R.string.follow_set_create_btn_label)) } } - - if (isSetAdditionDialogOpen.value) { - NewPeopleListCreationDialog( - title = title, - onDismiss = { - isSetAdditionDialogOpen.value = false - }, - onCreateList = { name, description -> - onAddSet(name, description) - }, - ) - } } 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 87e71d986..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 @@ -83,6 +83,7 @@ private fun PeopleListItemPreview() { identifierTag = "00001-2222", 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, ) { @@ -186,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, ) @@ -276,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, ) { @@ -291,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) } @@ -330,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() }, ) @@ -365,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, 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 index fd036ed02..184a8a926 100644 --- 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 @@ -23,7 +23,6 @@ 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.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Stable @@ -36,47 +35,6 @@ class PeopleListViewModel : ViewModel() { fun listFlow() = accountViewModel.account.peopleLists.uiListFlow - fun addItem( - title: String, - description: String?, - ) { - accountViewModel.launchSigner { - accountViewModel.account.peopleLists.addFollowList( - listName = title, - listDescription = description, - account = accountViewModel.account, - ) - } - } - - fun openItem(dTag: String) = Route.MyPeopleListView(dTag) - - fun renameItem( - followSet: PeopleList, - newValue: String, - ) { - accountViewModel.launchSigner { - accountViewModel.account.peopleLists.renameFollowList( - newName = newValue, - peopleList = followSet, - account = accountViewModel.account, - ) - } - } - - fun changeItemDescription( - followSet: PeopleList, - newDescription: String?, - ) { - accountViewModel.launchSigner { - accountViewModel.account.peopleLists.modifyFollowSetDescription( - newDescription = newDescription, - peopleList = followSet, - account = accountViewModel.account, - ) - } - } - fun cloneItem( followSet: PeopleList, customName: String?, 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/FollowListAndPackAndUserView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserView.kt index 74a4ff24b..e6d7391c7 100644 --- 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 @@ -42,8 +42,9 @@ 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.PeopleListFabsAndMenu +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 @@ -95,17 +96,8 @@ fun FollowListAndPackAndUserView( color = MaterialTheme.colorScheme.grayText, ) } - PeopleListFabsAndMenu( - title = R.string.follow_set_creation_dialog_title, - onAddSet = { title, description -> - accountViewModel.launchSigner { - accountViewModel.account.peopleLists.addFollowList( - listName = title, - listDescription = description, - account = accountViewModel.account, - ) - } - }, + NewListButton( + onClick = { nav.nav(Route.PeopleListMetadataEdit()) }, ) } } @@ -162,17 +154,8 @@ fun FollowListAndPackAndUserView( color = MaterialTheme.colorScheme.grayText, ) } - PeopleListFabsAndMenu( - title = R.string.follow_pack_creation_dialog_title, - onAddSet = { title, description -> - accountViewModel.launchSigner { - accountViewModel.account.followLists.addFollowList( - name = title, - desc = description, - account = accountViewModel.account, - ) - } - }, + NewListButton( + onClick = { nav.nav(Route.FollowPackMetadataEdit()) }, ) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 08bee6659..86da1355d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -562,6 +562,7 @@ Create list Copy/Clone list Rename list + Edit List Modify You are renaming from to.. @@ -1356,4 +1357,22 @@ 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/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt index 9d849eec9..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, 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() }) From ac291c996ce523d46b97ee13ad8af039b59b52c9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 12 Nov 2025 17:21:46 -0500 Subject: [PATCH 42/43] Adds on click to the UserProfile selections --- .../lists/memberEdit/FollowListAndPackAndUserView.kt | 6 ++++++ .../loggedIn/lists/memberEdit/FollowPackAndUserItem.kt | 6 +++++- .../loggedIn/lists/memberEdit/PeopleListAndUserItem.kt | 6 +++++- 3 files changed, 16 insertions(+), 2 deletions(-) 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 index e6d7391c7..cffd299fc 100644 --- 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 @@ -120,6 +120,9 @@ fun FollowListAndPackAndUserView( }, privateMemberSize = list.privateMembers.size, publicMemberSize = list.publicMembers.size, + onClick = { + nav.nav(Route.MyPeopleListView(list.identifierTag)) + }, onAddUserToList = { userShouldBePrivate -> accountViewModel.launchSigner { accountViewModel.account.peopleLists.addUserToSet( @@ -165,6 +168,9 @@ fun FollowListAndPackAndUserView( listHeader = list.title, userName = userName, isMember = list.publicMembers.contains(userToAddOrRemove), + onClick = { + nav.nav(Route.MyFollowPackView(list.identifierTag)) + }, onRemoveUser = { accountViewModel.launchSigner { accountViewModel.account.followLists.removeUserFromSet( 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 index 2c8b0a7bf..40fdd45b1 100644 --- 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 @@ -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 @@ -63,6 +64,7 @@ fun FollowPackAndUserMemberPreview() { isMember = true, memberSize = 2, onAddUserToList = {}, + onClick = {}, onRemoveUser = {}, ) } @@ -79,6 +81,7 @@ fun FollowPackAndUserNotMemberPreview() { isMember = false, memberSize = 2, onAddUserToList = {}, + onClick = {}, onRemoveUser = {}, ) } @@ -91,11 +94,12 @@ fun FollowPackAndUserItem( userName: String, isMember: Boolean, memberSize: Int, + onClick: () -> Unit, onAddUserToList: () -> Unit, onRemoveUser: () -> Unit, ) { ListItem( - modifier = modifier, + modifier = modifier.clickable(onClick = onClick), headlineContent = { Text( text = listHeader, 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 d66a4b1b5..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, From ee52f101920864a8c37e45ea53d0ba5bb6e63939 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 12 Nov 2025 18:19:28 -0500 Subject: [PATCH 43/43] removes unecessary list of icons option from drawer --- .../ui/navigation/drawer/DrawerContent.kt | 50 +++---------------- 1 file changed, 8 insertions(+), 42 deletions(-) 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,