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 013f94fb6f..09d9dce793 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 @@ -99,6 +99,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.CommunitiesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity.EditCommunityScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity.NewCommunityScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.contactList.ContactListUsersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.LongFormPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen @@ -370,6 +371,7 @@ fun BuildNavigation( composableFromEndArgs { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ProfileScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ThreadScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { ContactListUsersScreen(it.noteId, accountViewModel, nav) } composableFromEndArgs { HashtagScreen(it, accountViewModel, nav) } composableFromEndArgs { GeoHashScreen(it, accountViewModel, nav) } composableFromEndArgs { RelayFeedScreen(it, 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 ba8c64e22b..6b68a5ee88 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 @@ -350,6 +350,10 @@ sealed class Route { val id: String, ) : Route() + @Serializable data class ContactListUsers( + val noteId: String, + ) : Route() + @Serializable data class Hashtag( val hashtag: String, ) : Route() 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 c8d01409da..a97e583caa 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 @@ -93,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList +import com.vitorpamplona.amethyst.ui.note.types.DisplayContactList import com.vitorpamplona.amethyst.ui.note.types.DisplayDMRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList import com.vitorpamplona.amethyst.ui.note.types.DisplayIndexerRelayList @@ -215,6 +216,7 @@ import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -974,6 +976,10 @@ private fun RenderNoteRow( DisplayFollowList(baseNote, true, accountViewModel, nav) } + is ContactListEvent -> { + DisplayContactList(baseNote, accountViewModel, nav) + } + is RelaySetEvent -> { DisplayRelaySet(baseNote, backgroundColor, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ContactList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ContactList.kt new file mode 100644 index 0000000000..e99fad4268 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ContactList.kt @@ -0,0 +1,116 @@ +/* + * 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.note.types + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +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.GalleryUnloaded +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import kotlinx.collections.immutable.toImmutableList + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun DisplayContactList( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = baseNote.event as? ContactListEvent ?: return + + val follows = remember(noteEvent) { noteEvent.verifiedFollowKeySet().toImmutableList() } + + Column( + modifier = + Modifier + .fillMaxWidth() + .clickable { nav.nav(Route.ContactListUsers(baseNote.idHex)) } + .padding(10.dp), + verticalArrangement = SpacedBy5dp, + ) { + Text( + text = stringRes(R.string.contact_list_containing_label, follows.size), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + GalleryUnloaded(follows, Modifier, accountViewModel, nav) + } +} + +@Preview +@Composable +fun DisplayContactListPreview() { + val accountViewModel = mockAccountViewModel() + + val contactList = + ContactListEvent( + id = "1d2f70ad06b65c4d3582d05dbc1e58fcf5b1d4b39a4ce4d54f3eeb1cb1b9b9aa", + pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + createdAt = 1761736286, + tags = + arrayOf( + arrayOf("p", "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da"), + arrayOf("p", "9a9a4aa0e43e57873380ab22e8a3df12f3c4cf5bb3a804c6e3fed0069a6e2740"), + arrayOf("p", "4f5dd82517b11088ce00f23d99f06fe8f3e2e45ecf47bc9c2f90f34d5c6f7382"), + arrayOf("p", "ac92102a2ecb873c488e0125354ef5a97075a16198668c360eda050007ed42cd"), + arrayOf("p", "47f54409a4620eb35208a3bc1b53555bf3d0656b246bf0471a93208e20672f6f"), + arrayOf("p", "2624911545afb7a2b440cf10f5c69308afa33aae26fca664d8c94623dc0f1baf"), + arrayOf("p", "6641f26f5c59f7010dbe3e42e4593398e27c087497cb7d20e0e7633a17e48a94"), + arrayOf("p", "ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b"), + arrayOf("p", "7eb29c126b3628077e2e3d863b917a56b74293aa9d8a9abc26a40ba3f2866baf"), + ), + content = "", + sig = "", + ) + + LocalCache.justConsume(contactList, null, false) + + ThemeComparisonColumn { + DisplayContactList( + baseNote = LocalCache.getOrCreateAddressableNote(contactList.address()), + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/contactList/ContactListUsersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/contactList/ContactListUsersScreen.kt new file mode 100644 index 0000000000..f6f96654d2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/contactList/ContactListUsersScreen.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.contactList + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +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.Scaffold +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 com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.UserCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList + +@Composable +fun ContactListUsersScreen( + noteId: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadNote(baseNoteHex = noteId, accountViewModel) { note -> + if (note == null) { + Scaffold( + topBar = { + TopBarWithBackButton( + caption = stringRes(R.string.contact_list_screen_title), + nav = nav, + ) + }, + ) { padding -> + Box( + modifier = Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + Text(stringRes(R.string.fetching_follow_list)) + } + } + } else { + val users by observeNoteEventAndMap>(note, accountViewModel) { event -> + event + ?.verifiedFollowKeySet() + ?.filter { !accountViewModel.account.isHidden(it) } + ?.mapNotNull { accountViewModel.checkGetOrCreateUser(it) } + ?.sortedByDescending { accountViewModel.account.isKnown(it.pubkeyHex) } + ?.toPersistentList() + ?: persistentListOf() + } + + Scaffold( + topBar = { + TopBarWithBackButton( + caption = stringRes(R.string.contact_list_screen_title_with_count, users.size), + nav = nav, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + ) { + itemsIndexed(users, key = { _, item -> item.pubkeyHex }) { _, item -> + UserCompose(item, accountViewModel = accountViewModel, nav = nav) + HorizontalDivider(thickness = DividerThickness) + } + } + } + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5f2c6fe9c3..11d207a6c3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2433,6 +2433,9 @@ Remove user from the list Follow Pack Members + Follow list containing %1$d user(s): + Follow list + Follow list (%1$d) Follow List Metadata Follow lists metadata can be seen by anyone on Nostr. Only your private members are encrypted.