From 30a78c5922a23450a213e0ccbeae94fbe27e31aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 17:56:18 +0000 Subject: [PATCH 1/5] feat: NIP-72 compliance fixes + standalone Communities screen Quartz (NIP-72 compliance) - Fix CommunityDefinition image() builder to use the NIP-72 ImageTag (was wrongly pointing at the NIP-23 ImageTag, which prevented the builder from attaching `x` dimensions). - Fix CommunityPostApproval notifyAuthor() to emit a `p` tag for the post author as required by NIP-72 (was duplicating the `e` tag). - Add RelayTag.MARKER_AUTHOR/REQUESTS/APPROVALS constants. Amethyst - Communities screen - New top-level Communities screen with FeedFilterSpinner TopNav that supports the Mine option (filters to communities authored by the logged-in user). - New Route.Communities + drawer entry. - New CommunitiesFeedFilter (subclass of DiscoverCommunityFeedFilter using defaultCommunitiesFollowList + Mine handling). - New CommunitiesListFilterAssembler/SubAssembler with dedicated filterCommunitiesMine relay query. - Account.liveCommunitiesFollowLists + settings.defaultCommunitiesFollowList. - TopNavFilterState.communityRoutes (includes Mine). Amethyst - Create Community - Route.NewCommunity + FAB on the Communities screen. - NewCommunityModel + Material3 form: name, description, image URL, rules, moderator pubkeys, relay requests/approvals URLs. - Signs a kind 34550 CommunityDefinitionEvent and auto-follows it so it appears under "Mine". - Account.sendCommunityDefinition helper. Misc - Extracted parseTopFilterOrDefault helper in LocalPreferences to keep the existing inner load method under the JVM method-size limit. --- .../amethyst/LocalPreferences.kt | 34 +++- .../vitorpamplona/amethyst/model/Account.kt | 33 ++++ .../amethyst/model/AccountSettings.kt | 12 ++ .../RelaySubscriptionsCoordinator.kt | 3 + .../ui/feeds/RememberForeverStates.kt | 1 + .../amethyst/ui/navigation/AppNavigation.kt | 4 + .../ui/navigation/drawer/DrawerContent.kt | 9 + .../amethyst/ui/navigation/routes/Routes.kt | 4 + .../amethyst/ui/screen/TopNavFilterState.kt | 17 ++ .../loggedIn/AccountFeedContentStates.kt | 7 + .../communities/list/CommunitiesScreen.kt | 180 ++++++++++++++++++ .../communities/list/CommunitiesTopBar.kt | 70 +++++++ .../communities/list/NewCommunityButton.kt | 53 ++++++ .../list/dal/CommunitiesFeedFilter.kt | 125 ++++++++++++ .../CommunitiesListFilterAssembler.kt | 50 +++++ ...munitiesListFilterAssemblerSubscription.kt | 48 +++++ .../datasource/CommunitiesListSubAssembler.kt | 104 ++++++++++ .../list/datasource/FilterCommunitiesMine.kt | 51 +++++ .../newCommunity/NewCommunityModel.kt | 163 ++++++++++++++++ .../newCommunity/NewCommunityScreen.kt | 173 +++++++++++++++++ amethyst/src/main/res/values/strings.xml | 11 ++ .../approval/TagArrayBuilderExt.kt | 5 +- .../definition/TagArrayBuilderExt.kt | 8 +- .../definition/tags/RelayTag.kt | 4 + 24 files changed, 1154 insertions(+), 15 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesTopBar.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/NewCommunityButton.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/dal/CommunitiesFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListSubAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/FilterCommunitiesMine.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 8e9f39da6..956685e7d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -547,17 +547,17 @@ object LocalPreferences { Log.d("LocalPreferences") { "Load account from file $npub - before parsing events" } - val defaultHomeFollowList = async { parseOrNull(defaultHomeFollowListStr) ?: TopFilter.AllFollows } - val defaultStoriesFollowList = async { parseOrNull(defaultStoriesFollowListStr) ?: TopFilter.Global } - val defaultNotificationFollowList = async { parseOrNull(defaultNotificationFollowListStr) ?: TopFilter.Global } - val defaultDiscoveryFollowList = async { parseOrNull(defaultDiscoveryFollowListStr) ?: TopFilter.Global } + val defaultHomeFollowList = async { parseTopFilterOrDefault(defaultHomeFollowListStr, TopFilter.AllFollows) } + val defaultStoriesFollowList = async { parseTopFilterOrDefault(defaultStoriesFollowListStr, TopFilter.Global) } + val defaultNotificationFollowList = async { parseTopFilterOrDefault(defaultNotificationFollowListStr, TopFilter.Global) } + val defaultDiscoveryFollowList = async { parseTopFilterOrDefault(defaultDiscoveryFollowListStr, TopFilter.Global) } - val defaultPollsFollowList = async { parseOrNull(defaultPollsFollowListStr) ?: TopFilter.Global } - val defaultPicturesFollowList = async { parseOrNull(defaultPicturesFollowListStr) ?: TopFilter.Global } - val defaultProductsFollowList = async { parseOrNull(defaultProductsFollowListStr) ?: TopFilter.AroundMe } - val defaultShortsFollowList = async { parseOrNull(defaultShortsFollowListStr) ?: TopFilter.Global } - val defaultLongsFollowList = async { parseOrNull(defaultLongsFollowListStr) ?: TopFilter.Global } - val defaultArticlesFollowList = async { parseOrNull(defaultArticlesFollowListStr) ?: TopFilter.AllFollows } + val defaultPollsFollowList = async { parseTopFilterOrDefault(defaultPollsFollowListStr, TopFilter.Global) } + val defaultPicturesFollowList = async { parseTopFilterOrDefault(defaultPicturesFollowListStr, TopFilter.Global) } + val defaultProductsFollowList = async { parseTopFilterOrDefault(defaultProductsFollowListStr, TopFilter.AroundMe) } + val defaultShortsFollowList = async { parseTopFilterOrDefault(defaultShortsFollowListStr, TopFilter.Global) } + val defaultLongsFollowList = async { parseTopFilterOrDefault(defaultLongsFollowListStr, TopFilter.Global) } + val defaultArticlesFollowList = async { parseTopFilterOrDefault(defaultArticlesFollowListStr, TopFilter.AllFollows) } val nwcWalletsLoaded = async { @@ -672,6 +672,20 @@ object LocalPreferences { return result } + private fun parseTopFilterOrDefault( + value: String?, + default: TopFilter, + ): TopFilter { + if (value.isNullOrEmpty() || value == "null") return default + return try { + JsonMapper.fromJson(value) + } catch (e: Throwable) { + if (e is CancellationException) throw e + Log.w("LocalPreferences", "Error Decoding TopFilter from Preferences with value $value", e) + default + } + } + private inline fun parseOrNull(value: String?): T? { if (value.isNullOrEmpty() || value == "null") { return null 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 a10e9fc2d..d9959c8d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -223,6 +223,8 @@ import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent @@ -481,6 +483,9 @@ class Account( val liveBadgesFollowLists: StateFlow = topNavFilterFlow(settings.defaultBadgesFollowList) val liveBadgesFollowListsPerRelay = OutboxLoaderState(liveBadgesFollowLists, cache, scope).flow + val liveCommunitiesFollowLists: StateFlow = topNavFilterFlow(settings.defaultCommunitiesFollowList) + val liveCommunitiesFollowListsPerRelay = OutboxLoaderState(liveCommunitiesFollowLists, cache, scope).flow + override fun isWriteable(): Boolean = settings.isWriteable() suspend fun updateWarnReports(warnReports: Boolean): Boolean { @@ -1161,6 +1166,34 @@ class Account( client.publish(signedEvent, relays) } + suspend fun sendCommunityDefinition( + name: String, + description: String, + moderators: List, + image: String? = null, + rules: String? = null, + relays: List? = null, + dTag: String, + ): CommunityDefinitionEvent? { + if (!isWriteable()) return null + + val template = + CommunityDefinitionEvent.build( + name = name, + description = description, + moderators = moderators, + image = image, + rules = rules, + relays = relays, + dTag = dTag, + ) + val signedEvent = signer.sign(template) + + cache.justConsumeMyOwnEvent(signedEvent) + client.publish(signedEvent, computeRelayListToBroadcast(signedEvent)) + return signedEvent + } + private fun loadCurrentAcceptedBadges(): List { val newNote = cache.getAddressableNoteIfExists(ProfileBadgesEvent.createAddress(signer.pubKey)) val newEvent = newNote?.event as? ProfileBadgesEvent 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 e1d9ac714..118930bd2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -191,6 +191,7 @@ class AccountSettings( val defaultLongsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultArticlesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val defaultBadgesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Mine), + val defaultCommunitiesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val nwcWallets: MutableStateFlow> = MutableStateFlow(emptyList()), val defaultNwcWalletId: MutableStateFlow = MutableStateFlow(null), var hideDeleteRequestDialog: Boolean = false, @@ -466,6 +467,17 @@ class AccountSettings( } } + fun changeDefaultCommunitiesFollowList(name: FeedDefinition) { + changeDefaultCommunitiesFollowList(name.code) + } + + fun changeDefaultCommunitiesFollowList(name: TopFilter) { + if (defaultCommunitiesFollowList.value != name) { + defaultCommunitiesFollowList.tryEmit(name) + saveAccountSettings() + } + } + fun changeDefaultPicturesFollowList(name: FeedDefinition) { changeDefaultPicturesFollowList(name.code) } 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 3d70480bd..14fe3f084 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 @@ -35,6 +35,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.chess.datasource.ChessFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource.CommunitiesListFilterAssembler 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 @@ -101,6 +102,7 @@ class RelaySubscriptionsCoordinator( val articles = ArticlesFilterAssembler(client) val badges = BadgesFilterAssembler(client) val profileBadges = ProfileBadgesFilterAssembler(client) + val communitiesList = CommunitiesListFilterAssembler(client) // active when sending zaps via NWC val nwc = NWCPaymentFilterAssembler(client) @@ -120,6 +122,7 @@ class RelaySubscriptionsCoordinator( articles, badges, profileBadges, + communitiesList, channelFinder, eventFinder, userFinder, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index 41de5c811..9fc9e2ffc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -59,6 +59,7 @@ object ScrollStateKeys { const val POLLS_OPEN = "PollsOpenFeed" const val POLLS_CLOSED = "PollsClosedFeed" const val BADGES_SCREEN = "BadgesFeed" + const val COMMUNITIES_LIST = "CommunitiesListFeed" const val PICTURES_SCREEN = "PicturesFeed" const val PRODUCTS_SCREEN = "ProductsFeed" const val SHORTS_SCREEN = "ShortsFeed" 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 90b1944a1..ba7c5b719 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 @@ -93,6 +93,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen 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.NewCommunityScreen 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 @@ -221,6 +223,8 @@ fun BuildNavigation( composable { DiscoverScreen(accountViewModel, nav) } composableArgs { NotificationScreen(it.scrollToEventId, accountViewModel, nav) } composableFromEnd { PollsScreen(accountViewModel, nav) } + composableFromEnd { CommunitiesScreen(accountViewModel, nav) } + composableFromEnd { NewCommunityScreen(accountViewModel, nav) } composableFromEnd { BadgesScreen(accountViewModel, nav) } composableFromEnd { ProfileBadgesScreen(accountViewModel, nav) } composableFromBottomArgs { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 000b5da78..9944fd5e2 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 @@ -58,6 +58,7 @@ import androidx.compose.material.icons.outlined.AccountBalanceWallet import androidx.compose.material.icons.outlined.CollectionsBookmark import androidx.compose.material.icons.outlined.Drafts import androidx.compose.material.icons.outlined.GroupAdd +import androidx.compose.material.icons.outlined.Groups import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.MilitaryTech import androidx.compose.material.icons.outlined.Photo @@ -619,6 +620,14 @@ fun ListContent( route = Route.Badges, ) + NavigationRow( + title = R.string.communities, + icon = Icons.Outlined.Groups, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.Communities, + ) + NavigationRow( title = R.string.discover_marketplace, icon = Icons.Outlined.Storefront, 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 96b1ad6a3..721b1e957 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 @@ -45,6 +45,10 @@ sealed class Route { @Serializable object Polls : Route() + @Serializable object Communities : Route() + + @Serializable object NewCommunity : Route() + @Serializable object Badges : Route() @Serializable object ProfileBadges : Route() 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 d008ad627..81cd62793 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 @@ -275,6 +275,18 @@ class TopNavFilterState( ) } + private val _communityRoutes = + livePeopleListsFlow.transform { peopleLists -> + checkNotInMainThread() + emit( + listOf( + listOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow), + peopleLists, + listOf(muteListFollow), + ).flatten().toImmutableList(), + ) + } + private val _kind3GlobalPeople = livePeopleListsFlow.transform { peopleLists -> checkNotInMainThread() @@ -302,6 +314,11 @@ class TopNavFilterState( .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow, muteListFollow)) + val communityRoutes = + _communityRoutes + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow, muteListFollow)) + fun destroy() { Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index ac6a5e30a..feb411df1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.dal.ArticlesFeedFi import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.dal.CommunitiesFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.DiscoverLongFormFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.DiscoverChatFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.DiscoverFollowSetsFeedFilter @@ -84,6 +85,8 @@ class AccountFeedContentStates( val badgesFeed = FeedContentState(BadgesFeedFilter(account), scope, LocalCache) + val communitiesList = FeedContentState(CommunitiesFeedFilter(account), scope, LocalCache) + val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache) val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache) val shortsFeed = FeedContentState(ShortsFeedFilter(account), scope, LocalCache) @@ -130,6 +133,8 @@ class AccountFeedContentStates( badgesFeed.updateFeedWith(newNotes) + communitiesList.updateFeedWith(newNotes) + picturesFeed.updateFeedWith(newNotes) productsFeed.updateFeedWith(newNotes) shortsFeed.updateFeedWith(newNotes) @@ -170,6 +175,8 @@ class AccountFeedContentStates( badgesFeed.deleteFromFeed(newNotes) + communitiesList.deleteFromFeed(newNotes) + picturesFeed.deleteFromFeed(newNotes) productsFeed.deleteFromFeed(newNotes) shortsFeed.deleteFromFeed(newNotes) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesScreen.kt new file mode 100644 index 000000000..7071950a7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesScreen.kt @@ -0,0 +1,180 @@ +/* + * 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.communities.list + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty +import com.vitorpamplona.amethyst.ui.feeds.FeedError +import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +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.communities.list.datasource.CommunitiesListFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.ChannelCardCompose +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent + +@Composable +fun CommunitiesScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + CommunitiesScreen( + feedContentState = accountViewModel.feedStates.communitiesList, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun CommunitiesScreen( + feedContentState: FeedContentState, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(feedContentState) + WatchAccountForCommunitiesScreen(feedContentState, accountViewModel) + CommunitiesListFilterAssemblerSubscription(accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + CommunitiesTopBar(accountViewModel, nav) + }, + bottomBar = { + AppBottomBar(Route.Communities, accountViewModel) { route -> + if (route == Route.Communities) { + feedContentState.sendToTop() + } else { + nav.newStack(route) + } + } + }, + floatingButton = { + NewCommunityButton(nav) + }, + accountViewModel = accountViewModel, + ) { + RefresheableBox(feedContentState, true) { + SaveableFeedContentState(feedContentState, scrollStateKey = ScrollStateKeys.COMMUNITIES_LIST) { listState -> + RenderCommunitiesFeed( + feedContentState = feedContentState, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} + +@Composable +private fun RenderCommunitiesFeed( + feedContentState: FeedContentState, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() + + CrossfadeIfEnabled( + targetState = feedState, + animationSpec = tween(durationMillis = 100), + label = "RenderCommunitiesFeed", + accountViewModel = accountViewModel, + ) { state -> + when (state) { + is FeedState.Empty -> FeedEmpty(feedContentState::invalidateData) + is FeedState.FeedError -> FeedError(state.errorMessage, feedContentState::invalidateData) + is FeedState.Loaded -> CommunitiesFeedLoaded(state, listState, accountViewModel, nav) + is FeedState.Loading -> LoadingFeed() + } + } +} + +@Composable +private fun CommunitiesFeedLoaded( + loaded: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + + LazyColumn( + contentPadding = rememberFeedContentPadding(FeedPadding), + state = listState, + ) { + itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item -> + Row(Modifier.fillMaxWidth().animateItem()) { + ChannelCardCompose( + baseNote = item, + routeForLastRead = "CommunitiesListFeed", + modifier = Modifier.fillMaxWidth(), + forceEventKind = CommunityDefinitionEvent.KIND, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + HorizontalDivider( + thickness = DividerThickness, + ) + } + } +} + +@Composable +private fun WatchAccountForCommunitiesScreen( + feedContentState: FeedContentState, + accountViewModel: AccountViewModel, +) { + val listState by accountViewModel.account.liveCommunitiesFollowLists.collectAsStateWithLifecycle() + val hiddenUsers = + accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() + + LaunchedEffect(accountViewModel, listState, hiddenUsers) { + feedContentState.checkKeysInvalidateDataAndSendToTop() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesTopBar.kt new file mode 100644 index 000000000..5c8957f05 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesTopBar.kt @@ -0,0 +1,70 @@ +/* + * 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.communities.list + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.FeedDefinition +import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun CommunitiesTopBar( + accountViewModel: AccountViewModel, + nav: INav, +) { + UserDrawerSearchTopBar(accountViewModel, nav) { + val list by accountViewModel.account.settings.defaultCommunitiesFollowList + .collectAsStateWithLifecycle() + + CommunitiesTopNavFilterBar( + followListsModel = accountViewModel.feedStates.feedListOptions, + listName = list, + accountViewModel = accountViewModel, + onChange = accountViewModel.account.settings::changeDefaultCommunitiesFollowList, + ) + } +} + +@Composable +private fun CommunitiesTopNavFilterBar( + followListsModel: TopNavFilterState, + listName: TopFilter, + accountViewModel: AccountViewModel, + onChange: (FeedDefinition) -> Unit, +) { + val allLists by followListsModel.communityRoutes.collectAsStateWithLifecycle() + + FeedFilterSpinner( + placeholderCode = listName, + explainer = stringRes(R.string.select_list_to_filter), + options = allLists, + onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + accountViewModel = accountViewModel, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/NewCommunityButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/NewCommunityButton.kt new file mode 100644 index 000000000..a16490b90 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/NewCommunityButton.kt @@ -0,0 +1,53 @@ +/* + * 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.communities.list + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +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.Size26Modifier +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier + +@Composable +fun NewCommunityButton(nav: INav) { + FloatingActionButton( + onClick = { nav.nav(Route.NewCommunity) }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = stringRes(id = R.string.new_community), + modifier = Size26Modifier, + tint = Color.White, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/dal/CommunitiesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/dal/CommunitiesFeedFilter.kt new file mode 100644 index 000000000..3b5decf1a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/dal/CommunitiesFeedFilter.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.filterIntoSet +import com.vitorpamplona.amethyst.model.mapNotNullIntoSet +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.DiscoverCommunityFeedFilter +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent + +class CommunitiesFeedFilter( + account: Account, +) : DiscoverCommunityFeedFilter(account) { + override fun feedKey(): String = account.userProfile().pubkeyHex + "-communities-" + followList().code + + override fun followList(): TopFilter = account.settings.defaultCommunitiesFollowList.value + + private fun myPubkey(): String = account.userProfile().pubkeyHex + + override fun feed(): List { + if (followList() == TopFilter.Mine) { + val me = myPubkey() + val notes = + LocalCache.addressables.filterIntoSet(CommunityDefinitionEvent.KIND) { _, note -> + val noteEvent = note.event + noteEvent is CommunityDefinitionEvent && noteEvent.pubKey == me + } + return sort(notes) + } + + val filterParams = + FilterByListParams.create( + followLists = account.liveCommunitiesFollowLists.value, + hiddenUsers = account.hiddenUsers.flow.value, + ) + + val notes = + LocalCache.addressables.mapNotNullIntoSet(CommunityDefinitionEvent.KIND) { key, note -> + val noteEvent = note.event + if (noteEvent == null && shouldInclude(key, filterParams, note.relays)) { + note + } else if (noteEvent is CommunityDefinitionEvent && filterParams.match(noteEvent, note.relays)) { + note + } else { + null + } + } + + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + override fun innerApplyFilter(collection: Collection): Set { + if (followList() == TopFilter.Mine) { + val me = myPubkey() + return collection + .filterTo(HashSet()) { + val noteEvent = it.event + noteEvent is CommunityDefinitionEvent && noteEvent.pubKey == me + } + } + + val filterParams = + FilterByListParams.create( + followLists = account.liveCommunitiesFollowLists.value, + hiddenUsers = account.hiddenUsers.flow.value, + ) + + return collection + .mapNotNull { note -> + val noteEvent = note.event + if (noteEvent is CommunityDefinitionEvent && filterParams.match(noteEvent, note.relays)) { + listOf(note) + } else if (noteEvent is CommunityPostApprovalEvent) { + noteEvent.communityAddresses().mapNotNull { + val definitionNote = LocalCache.getOrCreateAddressableNote(it) + val definitionEvent = definitionNote.event + + if (definitionEvent == null && shouldInclude(it, filterParams, definitionNote.relays)) { + definitionNote + } else if (definitionEvent is CommunityDefinitionEvent && filterParams.match(definitionEvent, definitionNote.relays)) { + definitionNote + } else { + null + } + } + } else { + null + } + }.flatten() + .toSet() + } + + private fun shouldInclude( + aTag: Address?, + params: FilterByListParams, + comingFrom: List = emptyList(), + ) = aTag != null && aTag.kind == CommunityDefinitionEvent.KIND && params.match(aTag, comingFrom) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssembler.kt new file mode 100644 index 000000000..1ac72e047 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssembler.kt @@ -0,0 +1,50 @@ +/* + * 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.communities.list.datasource + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import kotlinx.coroutines.CoroutineScope + +class CommunitiesListQueryState( + val account: Account, + val feedStates: AccountFeedContentStates, + val scope: CoroutineScope, +) + +@Stable +class CommunitiesListFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + CommunitiesListSubAssembler(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/communities/list/datasource/CommunitiesListFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssemblerSubscription.kt new file mode 100644 index 000000000..a1db05935 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssemblerSubscription.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun CommunitiesListFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + CommunitiesListFilterAssemblerSubscription( + accountViewModel.dataSources().communitiesList, + accountViewModel, + ) +} + +@Composable +fun CommunitiesListFilterAssemblerSubscription( + dataSource: CommunitiesListFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(accountViewModel.account) { + CommunitiesListQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListSubAssembler.kt new file mode 100644 index 000000000..61cef800a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListSubAssembler.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.communities.list.datasource + +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.makeCommunitiesFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +class CommunitiesListSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: CommunitiesListQueryState, + since: SincePerRelayMap?, + ): List { + val listName = key.listName() + val defaultSince = key.feedStates.communitiesList.lastNoteCreatedAtIfFilled() + + return if (listName == TopFilter.Mine) { + val outbox = key.account.outboxRelays.flow.value + filterCommunitiesMine(key.account.userProfile().pubkeyHex, outbox, since) + } else { + makeCommunitiesFilter(key.followsPerRelay(), since, defaultSince) + } + } + + override fun user(key: CommunitiesListQueryState) = key.account.userProfile() + + override fun list(key: CommunitiesListQueryState) = key.listName() + + fun CommunitiesListQueryState.listNameFlow() = account.settings.defaultCommunitiesFollowList + + fun CommunitiesListQueryState.listName() = listNameFlow().value + + fun CommunitiesListQueryState.followsPerRelayFlow() = account.liveCommunitiesFollowListsPerRelay + + fun CommunitiesListQueryState.followsPerRelay() = followsPerRelayFlow().value + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: CommunitiesListQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.scope.launch(Dispatchers.IO) { + key.listNameFlow().collectLatest { + invalidateFilters() + } + }, + key.scope.launch(Dispatchers.IO) { + key.followsPerRelayFlow().sample(500).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.IO) { + key.feedStates.communitiesList.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/FilterCommunitiesMine.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/FilterCommunitiesMine.kt new file mode 100644 index 000000000..12593aae0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/FilterCommunitiesMine.kt @@ -0,0 +1,51 @@ +/* + * 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.communities.list.datasource + +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent + +private const val COMMUNITIES_MINE_LIMIT = 300 + +fun filterCommunitiesMine( + pubkey: HexKey, + relays: Set, + since: SincePerRelayMap?, +): List { + if (relays.isEmpty() || pubkey.isEmpty()) return emptyList() + val authors = listOf(pubkey) + return relays.map { relay -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(CommunityDefinitionEvent.KIND), + authors = authors, + limit = COMMUNITIES_MINE_LIMIT, + since = since?.get(relay)?.time, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt new file mode 100644 index 000000000..bf6f749bd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt @@ -0,0 +1,163 @@ +/* + * 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.communities.newCommunity + +import android.content.Context +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.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Stable +class NewCommunityModel : ViewModel() { + var account: Account? = null + + var isPublishing by mutableStateOf(false) + + var name by mutableStateOf("") + var description by mutableStateOf("") + var imageUrl by mutableStateOf("") + var rules by mutableStateOf("") + var moderatorsText by mutableStateOf("") + var relayRequestsUrl by mutableStateOf("") + var relayApprovalsUrl by mutableStateOf("") + + fun init(account: Account) { + if (this.account == account) return + this.account = account + } + + fun canPost(): Boolean = + !isPublishing && + name.isNotBlank() && + description.isNotBlank() + + @OptIn(ExperimentalUuidApi::class) + fun publish( + context: Context, + onSuccess: () -> Unit, + onError: (String, String) -> Unit, + ) = try { + publishUnsafe(context, onSuccess, onError) + } catch (e: SignerExceptions.ReadOnlyException) { + onError( + stringRes(context, R.string.read_only_user), + stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events), + ) + } + + @OptIn(ExperimentalUuidApi::class) + private fun publishUnsafe( + context: Context, + onSuccess: () -> Unit, + onError: (String, String) -> Unit, + ) { + val myAccount = account ?: return + + viewModelScope.launch(Dispatchers.IO) { + isPublishing = true + try { + val moderatorTags = parseModeratorTags(myAccount) + val relayTags = parseRelayTags() + + val definition = + myAccount.sendCommunityDefinition( + name = name.trim(), + description = description.trim(), + moderators = moderatorTags, + image = imageUrl.trim().ifBlank { null }, + rules = rules.trim().ifBlank { null }, + relays = relayTags.ifEmpty { null }, + dTag = Uuid.random().toString(), + ) + + if (definition == null) { + onError( + stringRes(context, R.string.read_only_user), + stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events), + ) + return@launch + } + + // Also follow the community so it appears on the user's list. + val communityNote = myAccount.cache.getOrCreateAddressableNote(definition.address()) + myAccount.follow(communityNote) + + reset() + onSuccess() + } finally { + isPublishing = false + } + } + } + + private fun parseModeratorTags(account: Account): List { + val hexes = + moderatorsText + .lineSequence() + .map { it.trim() } + .filter { it.length == 64 && it.all { ch -> ch.isDigit() || (ch in 'a'..'f') || (ch in 'A'..'F') } } + .map { it.lowercase() } + .toSet() + + val withOwner = hexes + account.signer.pubKey + + return withOwner.map { ModeratorTag(it, null, "moderator") } + } + + private fun parseRelayTags(): List { + val tags = mutableListOf() + relayRequestsUrl.trim().takeIf { it.isNotEmpty() }?.let { + RelayUrlNormalizer.normalizeOrNull(it)?.let { url -> + tags += RelayTag(url, RelayTag.MARKER_REQUESTS) + } + } + relayApprovalsUrl.trim().takeIf { it.isNotEmpty() }?.let { + RelayUrlNormalizer.normalizeOrNull(it)?.let { url -> + tags += RelayTag(url, RelayTag.MARKER_APPROVALS) + } + } + return tags + } + + fun reset() { + name = "" + description = "" + imageUrl = "" + rules = "" + moderatorsText = "" + relayRequestsUrl = "" + relayApprovalsUrl = "" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt new file mode 100644 index 000000000..fe4055a4a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt @@ -0,0 +1,173 @@ +/* + * 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.communities.newCommunity + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +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.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.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +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.stringRes + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewCommunityScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val model: NewCommunityModel = viewModel() + + LaunchedEffect(accountViewModel.account) { + model.init(accountViewModel.account) + } + + val context = LocalContext.current + var errorTitle by remember { mutableStateOf(null) } + var errorBody by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopBarWithBackButton( + caption = stringRes(R.string.new_community), + popBack = { nav.popBack() }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp, vertical = 12.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = model.name, + onValueChange = { model.name = it }, + label = { Text(stringRes(R.string.new_community_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = model.description, + onValueChange = { model.description = it }, + label = { Text(stringRes(R.string.new_community_description)) }, + minLines = 3, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = model.imageUrl, + onValueChange = { model.imageUrl = it }, + label = { Text(stringRes(R.string.new_community_image_url)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = model.rules, + onValueChange = { model.rules = it }, + label = { Text(stringRes(R.string.new_community_rules)) }, + minLines = 3, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = model.moderatorsText, + onValueChange = { model.moderatorsText = it }, + label = { Text(stringRes(R.string.new_community_moderators)) }, + minLines = 2, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = model.relayRequestsUrl, + onValueChange = { model.relayRequestsUrl = it }, + label = { Text(stringRes(R.string.new_community_relay_requests)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = model.relayApprovalsUrl, + onValueChange = { model.relayApprovalsUrl = it }, + label = { Text(stringRes(R.string.new_community_relay_approvals)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + errorTitle?.let { title -> + Text( + text = title, + fontWeight = FontWeight.Bold, + ) + errorBody?.let { body -> + Text(text = body) + } + } + + Button( + onClick = { + errorTitle = null + errorBody = null + model.publish( + context = context, + onSuccess = { nav.popBack() }, + onError = { title, body -> + errorTitle = title + errorBody = body + }, + ) + }, + enabled = model.canPost(), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 10.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringRes(R.string.new_community_publish)) + } + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 510936a64..6cc43d0fc 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -420,6 +420,17 @@ Open Closed Badges + Communities + New Community + Community name + Description + Cover image URL (optional) + Rules (optional) + Moderators (one pubkey per line, optional) + Relay for requests (optional) + Relay for approvals (optional) + Publish + No communities match this filter. Received Mine Awarded diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/TagArrayBuilderExt.kt index 3b55ac56b..ecedc922c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/TagArrayBuilderExt.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.tags.aTag.toATag import com.vitorpamplona.quartz.nip01Core.tags.events.toETagArray +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent fun TagArrayBuilder.community(event: EventHintBundle) = add(event.toATag().toATagArray()) @@ -37,6 +38,4 @@ fun TagArrayBuilder.approved(event: EventHintBundle< } } -fun TagArrayBuilder.notifyAuthor(event: EventHintBundle) { - add(event.toETagArray()) -} +fun TagArrayBuilder.notifyAuthor(event: EventHintBundle) = add(PTag.assemble(event.event.pubKey, event.authorHomeRelay)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/TagArrayBuilderExt.kt index 0fa5adefe..c5d5ed0d8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/TagArrayBuilderExt.kt @@ -21,18 +21,22 @@ package com.vitorpamplona.quartz.nip72ModCommunities.definition import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder -import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.DescriptionTag +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ImageTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.NameTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RulesTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) fun TagArrayBuilder.description(description: String) = addUnique(DescriptionTag.assemble(description)) -fun TagArrayBuilder.image(webUrl: String) = addUnique(ImageTag.assemble(webUrl)) +fun TagArrayBuilder.image( + webUrl: String, + dimensions: DimensionTag? = null, +) = addUnique(ImageTag.assemble(webUrl, dimensions)) fun TagArrayBuilder.rules(rules: String) = addUnique(RulesTag.assemble(rules)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt index 6398f4b55..bda3d1141 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt @@ -35,6 +35,10 @@ class RelayTag( companion object { const val TAG_NAME = "relay" + const val MARKER_AUTHOR = "author" + const val MARKER_REQUESTS = "requests" + const val MARKER_APPROVALS = "approvals" + fun parse(tag: Array): RelayTag? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } From 0f8498187966016726276bec8ebc861a33e2dd8d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 20:28:09 +0000 Subject: [PATCH 2/5] feat(communities): redesign New Community screen - Large tap-to-pick cover image at the top (Badge-style): shows an aspect-16:9 placeholder that opens the gallery, uploads to the user's chosen media server, and previews via ShowImageUploadGallery. - Moderators section: lists the current user as pinned "Owner" plus each added moderator (picture + display name + NIP-05) with a remove icon. The add field is powered by ShowUserSuggestionList so names, npubs and NIP-05s all search live. - Relays section: each row uses BasicRelaySetupInfoClickableRow so users see the full relay stats (icon, users, event counts, status) just like the AllRelayList screen. Below each row a FilterChip group sets the NIP-72 marker (any / author / requests / approvals). A RelayUrlEditField at the bottom adds new relays with live search suggestions. - Reworked NewCommunityModel: dedicated mutableStateListOf for moderators and relays, proper image upload via MultiOrchestrator, CommunityRelayEntry data class, publish() now uploads then signs kind 34550 and follows it (so the new community appears in "Mine"). - Updated strings and localized resources. --- .../newCommunity/NewCommunityModel.kt | 177 ++++-- .../newCommunity/NewCommunityScreen.kt | 563 +++++++++++++++--- amethyst/src/main/res/values/strings.xml | 18 +- 3 files changed, 618 insertions(+), 140 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt index bf6f749bd..2e480fb5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt @@ -21,24 +21,42 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity import android.content.Context +import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid +@Immutable +data class CommunityRelayEntry( + val url: NormalizedRelayUrl, + val marker: String? = null, +) + @Stable class NewCommunityModel : ViewModel() { var account: Account? = null @@ -47,15 +65,65 @@ class NewCommunityModel : ViewModel() { var name by mutableStateOf("") var description by mutableStateOf("") - var imageUrl by mutableStateOf("") var rules by mutableStateOf("") - var moderatorsText by mutableStateOf("") - var relayRequestsUrl by mutableStateOf("") - var relayApprovalsUrl by mutableStateOf("") + + // Image upload state - mirrors NewBadgeModel. + var multiOrchestrator by mutableStateOf(null) + var selectedServer by mutableStateOf(null) + + // 0 = Low, 1 = Medium, 2 = High, 3 = UNCOMPRESSED + var mediaQualitySlider by mutableIntStateOf(1) + var stripMetadata by mutableStateOf(true) + + val strippingFailureConfirmation = SuspendableConfirmation() + + // Moderator/Relay lists - backed by mutableStateListOf for direct Compose observation. + val moderators = mutableStateListOf() + val relays = mutableStateListOf() fun init(account: Account) { if (this.account == account) return this.account = account + this.selectedServer = defaultServer() + this.stripMetadata = account.settings.stripLocationOnUpload + } + + fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0] + + fun setPickedMedia(uris: ImmutableList) { + this.multiOrchestrator = if (uris.isNotEmpty()) MultiOrchestrator(uris) else null + } + + fun hasPickedImage(): Boolean = multiOrchestrator != null + + fun addModerator(user: User) { + if (moderators.none { it.pubkeyHex == user.pubkeyHex }) { + moderators.add(user) + } + } + + fun removeModerator(user: User) { + moderators.removeAll { it.pubkeyHex == user.pubkeyHex } + } + + fun addRelay(url: NormalizedRelayUrl) { + if (relays.none { it.url == url }) { + relays.add(CommunityRelayEntry(url)) + } + } + + fun removeRelay(entry: CommunityRelayEntry) { + relays.removeAll { it.url == entry.url } + } + + fun setRelayMarker( + entry: CommunityRelayEntry, + marker: String?, + ) { + val index = relays.indexOfFirst { it.url == entry.url } + if (index >= 0) { + relays[index] = entry.copy(marker = marker) + } } fun canPost(): Boolean = @@ -88,15 +156,33 @@ class NewCommunityModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { isPublishing = true try { - val moderatorTags = parseModeratorTags(myAccount) - val relayTags = parseRelayTags() + val imageUrl = + uploadImageIfAny(context, myAccount, onError) ?: run { + if (multiOrchestrator != null) { + // upload failed; error was reported, bail out + return@launch + } + null + } + + val ownerKey = myAccount.signer.pubKey + val moderatorTags = + buildList { + add(ModeratorTag(ownerKey, null, "moderator")) + moderators + .asSequence() + .filter { it.pubkeyHex != ownerKey } + .forEach { add(ModeratorTag(it.pubkeyHex, null, "moderator")) } + } + + val relayTags = relays.map { RelayTag(it.url, it.marker) } val definition = myAccount.sendCommunityDefinition( name = name.trim(), description = description.trim(), moderators = moderatorTags, - image = imageUrl.trim().ifBlank { null }, + image = imageUrl, rules = rules.trim().ifBlank { null }, relays = relayTags.ifEmpty { null }, dTag = Uuid.random().toString(), @@ -110,10 +196,13 @@ class NewCommunityModel : ViewModel() { return@launch } - // Also follow the community so it appears on the user's list. + // Follow it so it shows up under "Mine". val communityNote = myAccount.cache.getOrCreateAddressableNote(definition.address()) myAccount.follow(communityNote) + selectedServer?.let { myAccount.settings.changeDefaultFileServer(it) } + myAccount.settings.changeStripLocationOnUpload(stripMetadata) + reset() onSuccess() } finally { @@ -122,42 +211,58 @@ class NewCommunityModel : ViewModel() { } } - private fun parseModeratorTags(account: Account): List { - val hexes = - moderatorsText - .lineSequence() - .map { it.trim() } - .filter { it.length == 64 && it.all { ch -> ch.isDigit() || (ch in 'a'..'f') || (ch in 'A'..'F') } } - .map { it.lowercase() } - .toSet() + private suspend fun uploadImageIfAny( + context: Context, + myAccount: Account, + onError: (String, String) -> Unit, + ): String? { + val orch = multiOrchestrator ?: return null + val serverToUse = selectedServer ?: defaultServer() - val withOwner = hexes + account.signer.pubKey + val results = + orch.upload( + alt = name.trim().ifBlank { "Community cover" }, + contentWarningReason = null, + mediaQuality = MediaCompressor.intToCompressorQuality(mediaQualitySlider), + server = serverToUse, + account = myAccount, + context = context, + useH265 = false, + stripMetadata = stripMetadata, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + ) - return withOwner.map { ModeratorTag(it, null, "moderator") } - } - - private fun parseRelayTags(): List { - val tags = mutableListOf() - relayRequestsUrl.trim().takeIf { it.isNotEmpty() }?.let { - RelayUrlNormalizer.normalizeOrNull(it)?.let { url -> - tags += RelayTag(url, RelayTag.MARKER_REQUESTS) - } + if (!results.allGood) { + val messages = + results.errors + .map { stringRes(context, it.errorResource, *it.params) } + .distinct() + .joinToString(".\n") + onError(stringRes(context, R.string.failed_to_upload_media_no_details), messages) + return null } - relayApprovalsUrl.trim().takeIf { it.isNotEmpty() }?.let { - RelayUrlNormalizer.normalizeOrNull(it)?.let { url -> - tags += RelayTag(url, RelayTag.MARKER_APPROVALS) + + val uploaded = + results.successful.firstNotNullOfOrNull { + it.result as? UploadOrchestrator.OrchestratorResult.ServerResult + } ?: run { + onError( + stringRes(context, R.string.failed_to_upload_media_no_details), + "Upload succeeded but no image URL was returned by the server.", + ) + return null } - } - return tags + + return uploaded.url } fun reset() { name = "" description = "" - imageUrl = "" rules = "" - moderatorsText = "" - relayRequestsUrl = "" - relayApprovalsUrl = "" + multiOrchestrator = null + moderators.clear() + relays.clear() + selectedServer = defaultServer() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt index fe4055a4a..c68d871d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt @@ -20,35 +20,77 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity +import androidx.compose.foundation.border +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddPhotoAlternate +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +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 import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect 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.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog +import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect +import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoClickableRow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag +import kotlinx.collections.immutable.persistentListOf @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -57,117 +99,440 @@ fun NewCommunityScreen( nav: INav, ) { val model: NewCommunityModel = viewModel() + val context = LocalContext.current LaunchedEffect(accountViewModel.account) { model.init(accountViewModel.account) } - val context = LocalContext.current - var errorTitle by remember { mutableStateOf(null) } - var errorBody by remember { mutableStateOf(null) } + StrippingFailureDialog(model.strippingFailureConfirmation) + + var wantsToPickImage by remember { mutableStateOf(false) } + + if (wantsToPickImage) { + GallerySelect( + onImageUri = { uris -> + wantsToPickImage = false + model.setPickedMedia( + if (uris.isNotEmpty()) persistentListOf(uris.first()) else persistentListOf(), + ) + }, + ) + } Scaffold( topBar = { - TopBarWithBackButton( - caption = stringRes(R.string.new_community), - popBack = { nav.popBack() }, - ) - }, - ) { padding -> - Column( - modifier = - Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = 16.dp, vertical = 12.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlinedTextField( - value = model.name, - onValueChange = { model.name = it }, - label = { Text(stringRes(R.string.new_community_name)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = model.description, - onValueChange = { model.description = it }, - label = { Text(stringRes(R.string.new_community_description)) }, - minLines = 3, - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = model.imageUrl, - onValueChange = { model.imageUrl = it }, - label = { Text(stringRes(R.string.new_community_image_url)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = model.rules, - onValueChange = { model.rules = it }, - label = { Text(stringRes(R.string.new_community_rules)) }, - minLines = 3, - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = model.moderatorsText, - onValueChange = { model.moderatorsText = it }, - label = { Text(stringRes(R.string.new_community_moderators)) }, - minLines = 2, - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = model.relayRequestsUrl, - onValueChange = { model.relayRequestsUrl = it }, - label = { Text(stringRes(R.string.new_community_relay_requests)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = model.relayApprovalsUrl, - onValueChange = { model.relayApprovalsUrl = it }, - label = { Text(stringRes(R.string.new_community_relay_approvals)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - - errorTitle?.let { title -> - Text( - text = title, - fontWeight = FontWeight.Bold, - ) - errorBody?.let { body -> - Text(text = body) - } - } - - Button( - onClick = { - errorTitle = null - errorBody = null + CreatingTopBar( + titleRes = R.string.new_community, + isActive = model::canPost, + onCancel = { + model.reset() + nav.popBack() + }, + onPost = { model.publish( context = context, onSuccess = { nav.popBack() }, - onError = { title, body -> - errorTitle = title - errorBody = body - }, + onError = accountViewModel.toastManager::toast, ) }, - enabled = model.canPost(), - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 10.dp), - modifier = Modifier.fillMaxWidth(), + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding(), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Text(text = stringRes(R.string.new_community_publish)) + CommunityImagePicker( + model = model, + accountViewModel = accountViewModel, + onPickImage = { wantsToPickImage = true }, + ) + + OutlinedTextField( + value = model.name, + onValueChange = { model.name = it }, + label = { Text(stringRes(R.string.new_community_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + + OutlinedTextField( + value = model.description, + onValueChange = { model.description = it }, + label = { Text(stringRes(R.string.new_community_description)) }, + minLines = 3, + maxLines = 8, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + + OutlinedTextField( + value = model.rules, + onValueChange = { model.rules = it }, + label = { Text(stringRes(R.string.new_community_rules)) }, + minLines = 2, + maxLines = 8, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + + HorizontalDivider() + + SectionHeader(R.string.new_community_moderators_section) + ModeratorsSection( + model = model, + accountViewModel = accountViewModel, + nav = nav, + ) + + HorizontalDivider() + + SectionHeader(R.string.new_community_relays_section) + RelaysSection( + model = model, + accountViewModel = accountViewModel, + nav = nav, + ) } } } } + +@Composable +private fun SectionHeader(resourceId: Int) { + Text( + text = stringRes(resourceId), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) +} + +@Composable +private fun CommunityImagePicker( + model: NewCommunityModel, + accountViewModel: AccountViewModel, + onPickImage: () -> Unit, +) { + if (model.hasPickedImage()) { + model.multiOrchestrator?.let { + Box(modifier = Modifier.clickable(onClick = onPickImage)) { + ShowImageUploadGallery( + list = it, + onDelete = { model.setPickedMedia(persistentListOf()) }, + accountViewModel = accountViewModel, + ) + } + } + } else { + CommunityImagePlaceholder(onClick = onPickImage) + } +} + +@Composable +private fun CommunityImagePlaceholder(onClick: () -> Unit) { + Box( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(16.dp), + ).clickable(onClick = onClick) + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = Icons.Default.AddPhotoAlternate, + contentDescription = null, + modifier = Modifier.size(56.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringRes(R.string.new_community_pick_cover), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringRes(R.string.new_community_pick_cover_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +// --- Moderators -------------------------------------------------------------------------------- + +@Composable +private fun ModeratorsSection( + model: NewCommunityModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + var search by remember { mutableStateOf("") } + + val userSuggestions = + remember { + UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) + } + + DisposableEffect(Unit) { + onDispose { userSuggestions.reset() } + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringRes(R.string.new_community_moderators_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Current user is always a moderator (creator) + SelectedModeratorRow( + user = accountViewModel.account.userProfile(), + accountViewModel = accountViewModel, + nav = nav, + isOwner = true, + onRemove = null, + ) + + model.moderators.toList().forEach { user -> + SelectedModeratorRow( + user = user, + accountViewModel = accountViewModel, + nav = nav, + isOwner = false, + onRemove = { model.removeModerator(user) }, + ) + } + + OutlinedTextField( + value = search, + onValueChange = { + search = it + if (it.length > 1) { + userSuggestions.processCurrentWord(it) + } else { + userSuggestions.reset() + } + }, + label = { Text(stringRes(R.string.new_community_add_moderator)) }, + placeholder = { Text(stringRes(R.string.new_community_add_moderator_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + if (search.length > 1) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = { user -> + if (user.pubkeyHex != accountViewModel.account.userProfile().pubkeyHex) { + model.addModerator(user) + } + search = "" + userSuggestions.reset() + }, + accountViewModel = accountViewModel, + modifier = SuggestionListDefaultHeightPage, + ) + } + } +} + +@Composable +private fun SelectedModeratorRow( + user: User, + accountViewModel: AccountViewModel, + nav: INav, + isOwner: Boolean, + onRemove: (() -> Unit)?, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + UserPicture( + userHex = user.pubkeyHex, + size = 40.dp, + accountViewModel = accountViewModel, + nav = nav, + ) + Column( + modifier = Modifier.weight(1f).padding(start = 12.dp), + ) { + Text( + text = user.toBestDisplayName(), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + ModeratorSecondaryLine(user) + } + + if (isOwner) { + AssistChip( + onClick = {}, + label = { Text(stringRes(R.string.new_community_owner)) }, + enabled = false, + colors = + AssistChipDefaults.assistChipColors( + disabledContainerColor = MaterialTheme.colorScheme.primaryContainer, + disabledLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + } else if (onRemove != null) { + IconButton(onClick = onRemove) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = stringRes(R.string.remove), + ) + } + } + } +} + +@Composable +private fun ModeratorSecondaryLine(user: User) { + val nip05StateMetadata by user.nip05State().flow.collectAsStateWithLifecycle() + + val text = + when (val state = nip05StateMetadata) { + is Nip05State.Exists -> { + val name = state.nip05.name + if (name == "_") state.nip05.domain else "$name@${state.nip05.domain}" + } + + else -> { + user.pubkeyDisplayHex() + } + } + + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +// --- Relays ------------------------------------------------------------------------------------ + +@Composable +private fun RelaysSection( + model: NewCommunityModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringRes(R.string.new_community_relays_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + model.relays.toList().forEach { entry -> + val info = remember(entry.url) { relaySetupInfoBuilder(entry.url) } + + Column { + BasicRelaySetupInfoClickableRow( + item = info, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + onClick = {}, + onDelete = { model.removeRelay(entry) }, + nip11CachedRetriever = Amethyst.instance.nip11Cache, + accountViewModel = accountViewModel, + nav = nav, + ) + + RelayMarkerChips( + current = entry.marker, + onSelect = { model.setRelayMarker(entry, it) }, + ) + } + } + + RelayUrlEditField( + onNewRelay = { model.addRelay(it) }, + modifier = Modifier.fillMaxWidth(), + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +private fun RelayMarkerChips( + current: String?, + onSelect: (String?) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 56.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + RelayMarkerOption( + label = stringRes(R.string.new_community_relay_marker_none), + selected = current == null, + onClick = { onSelect(null) }, + ) + RelayMarkerOption( + label = stringRes(R.string.new_community_relay_marker_author), + selected = current == RelayTag.MARKER_AUTHOR, + onClick = { onSelect(RelayTag.MARKER_AUTHOR) }, + ) + RelayMarkerOption( + label = stringRes(R.string.new_community_relay_marker_requests), + selected = current == RelayTag.MARKER_REQUESTS, + onClick = { onSelect(RelayTag.MARKER_REQUESTS) }, + ) + RelayMarkerOption( + label = stringRes(R.string.new_community_relay_marker_approvals), + selected = current == RelayTag.MARKER_APPROVALS, + onClick = { onSelect(RelayTag.MARKER_APPROVALS) }, + ) + } +} + +@Composable +private fun RelayMarkerOption( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + FilterChip( + selected = selected, + onClick = onClick, + label = { Text(label, style = MaterialTheme.typography.labelSmall) }, + ) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6cc43d0fc..7474e1f40 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -424,12 +424,20 @@ New Community Community name Description - Cover image URL (optional) Rules (optional) - Moderators (one pubkey per line, optional) - Relay for requests (optional) - Relay for approvals (optional) - Publish + Add a cover image + Tap to pick a photo — it will be uploaded to your media server. + Moderators + Moderators can approve posts. You are always a moderator. + Add a moderator + Name, npub, or NIP-05 + Owner + Relays + Pick relays that will host requests, approvals, or the community author\'s metadata. + Any + Author + Requests + Approvals No communities match this filter. Received Mine From 30646998ea165e1d482ec149b3cd8955963905a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 21:05:06 +0000 Subject: [PATCH 3/5] feat(communities): prefer name() tag + add edit flow Name display - CommunityCard, CommunityName spinner option, FeedFilterSpinner renderer, and DisplayFollowingCommunityInPost now all prefer the NIP-72 `name` tag and fall back to the `d` tag. Previously they rendered the raw UUID d-tag for communities created by this app, which made them show up as /n/ instead of the configured name. - DisplayCommunity is now reactive via observeNote so the chip in NoteCompose recomposes when the community definition arrives. Edit flow - New Route.EditCommunity(kind, pubKeyHex, dTag) and EditCommunityScreen that reuses the create form via a shared CommunityFormScreen composable. - NewCommunityModel gains loadFrom(CommunityDefinitionEvent) to preload name/description/rules/moderators/relays and to remember the existing d-tag + image URL. publish() reuses the existing d-tag so the replaceable kind-34550 event updates in place. - Cover preview on the form supports the already-uploaded image (AsyncImage with remove button) and falls back to the placeholder if cleared. - Owner (note author == signer) sees an "Edit" FilledTonalIconButton in LongCommunityActionOptions that opens the edit screen. - Top bar becomes "Edit Community" with a Save button when editing. --- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 12 ++ .../navigation/topbars/FeedFilterSpinner.kt | 6 +- .../ui/note/elements/DisplayCommunity.kt | 34 ++-- .../amethyst/ui/note/types/CommunityHeader.kt | 21 +++ .../amethyst/ui/screen/TopNavFilterState.kt | 7 +- .../newCommunity/NewCommunityModel.kt | 68 ++++++-- .../newCommunity/NewCommunityScreen.kt | 152 +++++++++++++++--- .../nip72Communities/CommunityCard.kt | 2 +- amethyst/src/main/res/values/strings.xml | 1 + 10 files changed, 255 insertions(+), 50 deletions(-) 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 ba7c5b719..23ec04639 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 @@ -94,6 +94,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen 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.discover.DiscoverScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.LongFormPostScreen @@ -225,6 +226,7 @@ fun BuildNavigation( composableFromEnd { PollsScreen(accountViewModel, nav) } composableFromEnd { CommunitiesScreen(accountViewModel, nav) } composableFromEnd { NewCommunityScreen(accountViewModel, nav) } + composableFromEndArgs { EditCommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEnd { BadgesScreen(accountViewModel, nav) } composableFromEnd { ProfileBadgesScreen(accountViewModel, nav) } composableFromBottomArgs { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, 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 721b1e957..53d08be4b 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 @@ -49,6 +49,18 @@ sealed class Route { @Serializable object NewCommunity : Route() + @Serializable data class EditCommunity( + val kind: Int, + val pubKeyHex: HexKey, + val dTag: String, + ) : Route() { + constructor(address: Address) : this( + kind = address.kind, + pubKeyHex = address.pubKeyHex, + dTag = address.dTag, + ) + } + @Serializable object Badges : Route() @Serializable object ProfileBadges : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index cc178d3e6..dc2c775bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -104,6 +104,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import kotlinx.collections.immutable.ImmutableList @@ -351,7 +352,10 @@ fun RenderOption( is CommunityName -> { val it by observeNote(option.note, accountViewModel) - Text(text = "/n/${((it.note as? AddressableNote)?.dTag() ?: "")}", fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface) + val addressable = it.note as? AddressableNote + val definition = addressable?.event as? CommunityDefinitionEvent + val label = definition?.name()?.ifBlank { null } ?: addressable?.dTag() ?: "" + Text(text = "/n/$label", fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface) } is RelayName -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt index 68f928f2c..0ece66d12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt @@ -24,9 +24,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.components.ClickableTextColor import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -34,6 +37,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent @Composable fun DisplayFollowingCommunityInPost( @@ -42,38 +46,46 @@ fun DisplayFollowingCommunityInPost( nav: INav, ) { Column(HalfStartPadding) { - Row(verticalAlignment = Alignment.CenterVertically) { DisplayCommunity(baseNote, nav) } + Row(verticalAlignment = Alignment.CenterVertically) { DisplayCommunity(baseNote, accountViewModel, nav) } } } @Composable private fun DisplayCommunity( note: Note, + accountViewModel: AccountViewModel, nav: INav, ) { val communityTag = note.event?.communityAddress() ?: return + val communityNote = LocalCache.getOrCreateAddressableNote(communityTag) + val communityState by observeNote(communityNote, accountViewModel) + val label = communityShortLabel(communityTag, communityState.note.event as? CommunityDefinitionEvent) + ClickableTextColor( - getCommunityShortName(communityTag), + label, linkColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.52f), overflow = TextOverflow.Ellipsis, maxLines = 1, ) { nav.nav { - note.event?.communityAddress()?.let { communityTag -> - Route.Community(communityTag.kind, communityTag.pubKeyHex, communityTag.dTag) + note.event?.communityAddress()?.let { addr -> + Route.Community(addr.kind, addr.pubKeyHex, addr.dTag) } } } } -fun getCommunityShortName(communityAddress: Address): String { - val name = - if (communityAddress.dTag.length > 10) { - communityAddress.dTag.take(10) + "..." +private fun communityShortLabel( + address: Address, + definition: CommunityDefinitionEvent?, +): String { + val raw = definition?.name()?.ifBlank { null } ?: address.dTag + val shortened = + if (raw.length > 12) { + raw.take(12) + "..." } else { - communityAddress.dTag.take(10) + raw } - - return "/n/$name" + return "/n/$shortened" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt index 81725b40a..f61ddf1df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt @@ -34,6 +34,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilledTonalButton @@ -70,6 +71,7 @@ import com.vitorpamplona.amethyst.ui.components.RichTextViewer import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.LikeReaction @@ -439,6 +441,9 @@ private fun LongCommunityActionOptions( nav: INav, ) { Row { + if (note.author?.pubkeyHex == accountViewModel.account.signer.pubKey) { + EditCommunityButton(note, nav) + } ShareCommunityButton(accountViewModel, note, nav) WatchAddressableNoteFollows(note, accountViewModel) { isFollowing -> if (isFollowing) { @@ -448,6 +453,22 @@ private fun LongCommunityActionOptions( } } +@Composable +fun EditCommunityButton( + note: AddressableNote, + nav: INav, +) { + FilledTonalIconButton( + onClick = { nav.nav(Route.EditCommunity(note.address)) }, + ) { + Icon( + imageVector = Icons.Outlined.Edit, + modifier = Size18Modifier, + contentDescription = stringRes(R.string.edit_community), + ) + } +} + @Composable fun WatchAddressableNoteFollows( note: AddressableNote, 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 81cd62793..7c502ce92 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 @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.persistentListOf @@ -381,7 +382,11 @@ class PeopleListName( class CommunityName( val note: AddressableNote, ) : Name() { - override fun name() = "/n/${(note.dTag())}" + override fun name(): String { + val definition = note.event as? CommunityDefinitionEvent + val label = definition?.name()?.ifBlank { null } ?: note.dTag() + return "/n/$label" + } } @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt index 2e480fb5c..09c61bed8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag import kotlinx.collections.immutable.ImmutableList @@ -61,11 +62,16 @@ data class CommunityRelayEntry( class NewCommunityModel : ViewModel() { var account: Account? = null + // When set, publish() reuses this d-tag so the kind-34550 replaceable event + // is updated in place (edit flow). Null in the create flow. + var existingDTag: String? = null + var isPublishing by mutableStateOf(false) var name by mutableStateOf("") var description by mutableStateOf("") var rules by mutableStateOf("") + var existingImageUrl by mutableStateOf(null) // Image upload state - mirrors NewBadgeModel. var multiOrchestrator by mutableStateOf(null) @@ -88,6 +94,42 @@ class NewCommunityModel : ViewModel() { this.stripMetadata = account.settings.stripLocationOnUpload } + /** + * Preloads the form with the current contents of [existing] so the user can edit + * the replaceable kind 34550 event. Keeps the original `d` tag so relays replace + * the previous version instead of creating a new community. + */ + fun loadFrom(existing: CommunityDefinitionEvent) { + existingDTag = existing.dTag() + name = existing.name().orEmpty() + description = existing.description().orEmpty() + rules = existing.rules().orEmpty() + existingImageUrl = existing.image()?.imageUrl + + val ownerKey = account?.signer?.pubKey + moderators.clear() + existing + .moderatorKeys() + .asSequence() + .filter { it != ownerKey } + .distinct() + .forEach { pubkey -> + val user = account?.cache?.getOrCreateUser(pubkey) ?: return@forEach + if (moderators.none { it.pubkeyHex == user.pubkeyHex }) { + moderators.add(user) + } + } + + relays.clear() + existing.relays().forEach { tag -> + if (relays.none { it.url == tag.url }) { + relays.add(CommunityRelayEntry(tag.url, tag.marker)) + } + } + } + + fun isEditing(): Boolean = existingDTag != null + fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0] fun setPickedMedia(uris: ImmutableList) { @@ -156,15 +198,15 @@ class NewCommunityModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { isPublishing = true try { - val imageUrl = - uploadImageIfAny(context, myAccount, onError) ?: run { - if (multiOrchestrator != null) { - // upload failed; error was reported, bail out - return@launch - } + val uploadedUrl = + if (multiOrchestrator != null) { + uploadImageIfAny(context, myAccount, onError) ?: return@launch + } else { null } + val imageUrl = uploadedUrl ?: existingImageUrl + val ownerKey = myAccount.signer.pubKey val moderatorTags = buildList { @@ -177,6 +219,8 @@ class NewCommunityModel : ViewModel() { val relayTags = relays.map { RelayTag(it.url, it.marker) } + val dTag = existingDTag ?: Uuid.random().toString() + val definition = myAccount.sendCommunityDefinition( name = name.trim(), @@ -185,7 +229,7 @@ class NewCommunityModel : ViewModel() { image = imageUrl, rules = rules.trim().ifBlank { null }, relays = relayTags.ifEmpty { null }, - dTag = Uuid.random().toString(), + dTag = dTag, ) if (definition == null) { @@ -196,9 +240,11 @@ class NewCommunityModel : ViewModel() { return@launch } - // Follow it so it shows up under "Mine". - val communityNote = myAccount.cache.getOrCreateAddressableNote(definition.address()) - myAccount.follow(communityNote) + // Auto-follow only on the create flow; editing doesn't change the follow set. + if (existingDTag == null) { + val communityNote = myAccount.cache.getOrCreateAddressableNote(definition.address()) + myAccount.follow(communityNote) + } selectedServer?.let { myAccount.settings.changeDefaultFileServer(it) } myAccount.settings.changeStripLocationOnUpload(stripMetadata) @@ -260,6 +306,8 @@ class NewCommunityModel : ViewModel() { name = "" description = "" rules = "" + existingImageUrl = null + existingDTag = null multiOrchestrator = null moderators.clear() relays.clear() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt index c68d871d7..bef4e8283 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt @@ -71,14 +71,17 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.ActionTopBar import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList @@ -89,14 +92,30 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditF import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag import kotlinx.collections.immutable.persistentListOf -@OptIn(ExperimentalMaterial3Api::class) @Composable fun NewCommunityScreen( accountViewModel: AccountViewModel, nav: INav, +) = CommunityFormScreen(editing = null, accountViewModel = accountViewModel, nav = nav) + +@Composable +fun EditCommunityScreen( + editing: Address, + accountViewModel: AccountViewModel, + nav: INav, +) = CommunityFormScreen(editing = editing, accountViewModel = accountViewModel, nav = nav) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CommunityFormScreen( + editing: Address?, + accountViewModel: AccountViewModel, + nav: INav, ) { val model: NewCommunityModel = viewModel() val context = LocalContext.current @@ -105,6 +124,15 @@ fun NewCommunityScreen( model.init(accountViewModel.account) } + LaunchedEffect(editing) { + if (editing != null) { + val note = LocalCache.getOrCreateAddressableNote(editing) + (note.event as? CommunityDefinitionEvent)?.let { model.loadFrom(it) } + } else { + model.existingDTag = null + } + } + StrippingFailureDialog(model.strippingFailureConfirmation) var wantsToPickImage by remember { mutableStateOf(false) } @@ -122,21 +150,42 @@ fun NewCommunityScreen( Scaffold( topBar = { - CreatingTopBar( - titleRes = R.string.new_community, - isActive = model::canPost, - onCancel = { - model.reset() - nav.popBack() - }, - onPost = { - model.publish( - context = context, - onSuccess = { nav.popBack() }, - onError = accountViewModel.toastManager::toast, - ) - }, - ) + val titleRes = + if (model.isEditing()) R.string.edit_community else R.string.new_community + if (model.isEditing()) { + ActionTopBar( + titleRes = titleRes, + postRes = R.string.save, + isActive = model::canPost, + onCancel = { + model.reset() + nav.popBack() + }, + onPost = { + model.publish( + context = context, + onSuccess = { nav.popBack() }, + onError = accountViewModel.toastManager::toast, + ) + }, + ) + } else { + CreatingTopBar( + titleRes = titleRes, + isActive = model::canPost, + onCancel = { + model.reset() + nav.popBack() + }, + onPost = { + model.publish( + context = context, + onSuccess = { nav.popBack() }, + onError = accountViewModel.toastManager::toast, + ) + }, + ) + } }, ) { pad -> Surface( @@ -235,18 +284,69 @@ private fun CommunityImagePicker( accountViewModel: AccountViewModel, onPickImage: () -> Unit, ) { - if (model.hasPickedImage()) { - model.multiOrchestrator?.let { - Box(modifier = Modifier.clickable(onClick = onPickImage)) { - ShowImageUploadGallery( - list = it, - onDelete = { model.setPickedMedia(persistentListOf()) }, - accountViewModel = accountViewModel, - ) + val existingUrl = model.existingImageUrl + when { + model.hasPickedImage() -> { + model.multiOrchestrator?.let { + Box(modifier = Modifier.clickable(onClick = onPickImage)) { + ShowImageUploadGallery( + list = it, + onDelete = { model.setPickedMedia(persistentListOf()) }, + accountViewModel = accountViewModel, + ) + } } } - } else { - CommunityImagePlaceholder(onClick = onPickImage) + + !existingUrl.isNullOrBlank() -> { + ExistingCommunityCover( + url = existingUrl, + onClick = onPickImage, + onClear = { model.existingImageUrl = null }, + ) + } + + else -> { + CommunityImagePlaceholder(onClick = onPickImage) + } + } +} + +@Composable +private fun ExistingCommunityCover( + url: String, + onClick: () -> Unit, + onClear: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clickable(onClick = onClick), + contentAlignment = Alignment.TopEnd, + ) { + AsyncImage( + model = url, + contentDescription = null, + contentScale = androidx.compose.ui.layout.ContentScale.Crop, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(16.dp), + ), + ) + IconButton(onClick = onClear) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = stringRes(R.string.remove), + tint = MaterialTheme.colorScheme.onSurface, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt index fe246d8b6..266d8f9ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt @@ -96,7 +96,7 @@ fun RenderCommunitiesThumb( RenderCommunitiesThumb( CommunityCard( - name = noteEvent.dTag(), + name = noteEvent.name()?.ifBlank { null } ?: noteEvent.dTag(), description = noteEvent.description(), cover = noteEvent.image()?.imageUrl, moderators = noteEvent.moderatorKeys().toImmutableList(), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 7474e1f40..51e4f0fab 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -422,6 +422,7 @@ Badges Communities New Community + Edit Community Community name Description Rules (optional) From 868e674a1cdb2593c30cdb37a806d1a3cb944ac3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 21:25:30 +0000 Subject: [PATCH 4/5] perf(communities): stop rebuilding the card on every reaction/zap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RenderCommunitiesThumb was observing the full NoteState, which recomposes on every reaction, zap, boost or metadata tick. Each recomposition rebuilt the CommunityCard, re-parsing the definition tags and calling moderatorKeys().toImmutableList() — producing a brand-new ImmutableList instance. That new list identity then invalidated LaunchedEffect(key = moderators) in LoadModerators, re-running the expensive ParticipantListBuilder traversal (author + all replies + boosts + zaps + reactions + public-chat notes) per tick. Fixes: - Switch to observeNoteEvent so the thumb only recomposes when the definition event itself changes (including edits). Reactions/zaps are handled separately inside LikeReaction/ZapReaction. - remember(event.id) { CommunityCard(...) } so the card and its moderator list identity are allocated once per event version. - Stabilize LoadModerators' LaunchedEffect with (baseNote.idHex, moderators). Convert the hosts list to a Set for O(1) filtering, reuse a single ParticipantListBuilder instance, and skip the redundant .minus(hosts) call on the new set. --- .../nip72Communities/CommunityCard.kt | 45 ++++++++++++------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt index 266d8f9ba..c1478ea52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt @@ -54,7 +54,7 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByPr import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner @@ -91,16 +91,26 @@ fun RenderCommunitiesThumb( accountViewModel: AccountViewModel, nav: INav, ) { - val noteState by observeNote(baseNote, accountViewModel) - val noteEvent = noteState.note.event as? CommunityDefinitionEvent ?: return + // Narrow observation: we only care about the CommunityDefinitionEvent itself, + // not every reaction/zap/boost that would otherwise recompose the whole card. + val definition by observeNoteEvent(baseNote, accountViewModel) + val event = definition ?: return + + // Memoize card + moderator list identity so LaunchedEffect(moderators) in + // LoadModerators doesn't retrigger the ParticipantListBuilder traversal on + // every recomposition. + val card = + remember(event.id) { + CommunityCard( + name = event.name()?.ifBlank { null } ?: event.dTag(), + description = event.description(), + cover = event.image()?.imageUrl, + moderators = event.moderatorKeys().toImmutableList(), + ) + } RenderCommunitiesThumb( - CommunityCard( - name = noteEvent.name()?.ifBlank { null } ?: noteEvent.dTag(), - description = noteEvent.description(), - cover = noteEvent.image()?.imageUrl, - moderators = noteEvent.moderatorKeys().toImmutableList(), - ), + card, baseNote, accountViewModel, nav, @@ -193,16 +203,21 @@ fun LoadModerators( ) } - LaunchedEffect(key1 = moderators) { + // Keying by the note id + moderator identity keeps this effect from + // restarting on every recomposition (reaction/zap updates would otherwise + // reallocate `moderators` and rerun the expensive traversal below). + LaunchedEffect(key1 = baseNote.idHex, key2 = moderators) { launch(Dispatchers.IO) { + val authorHex = baseNote.author?.pubkeyHex val hosts = moderators.mapNotNull { part -> - if (part != baseNote.author?.pubkeyHex) { + if (part != authorHex) { LocalCache.checkGetOrCreateUser(part) } else { null } } + val hostSet = hosts.toSet() val topFilter = accountViewModel.account.liveDiscoveryFollowLists.value val discoveryTopFilterAuthors = @@ -217,15 +232,15 @@ fun LoadModerators( else -> null } - val followingKeySet = discoveryTopFilterAuthors + val builder = ParticipantListBuilder() val allParticipants = - ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts) + builder.followsThatParticipateOn(baseNote, discoveryTopFilterAuthors) - hostSet val newParticipantUsers = - if (followingKeySet == null) { + if (discoveryTopFilterAuthors == null) { val allFollows = accountViewModel.account.kind3FollowList.flow.value.authors val followingParticipants = - ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts) + builder.followsThatParticipateOn(baseNote, allFollows) - hostSet (hosts + followingParticipants + (allParticipants - followingParticipants)) .toImmutableList() From 81ab25fbbcbf1aca9de3c4715a6d3e6f1940b8bc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 21:40:27 +0000 Subject: [PATCH 5/5] fix(communities): always show Save on the edit screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edit/create top bar was keyed on model.isEditing(), which reads existingDTag set by loadFrom(). loadFrom runs in a LaunchedEffect after the first composition, so the first render of the edit screen briefly showed the "Create" button before flipping to "Save". Derive the top bar from the `editing: Address?` parameter instead — it is known synchronously at composition, so edit mode shows "Save" on frame one. --- .../communities/newCommunity/NewCommunityScreen.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt index bef4e8283..456a577c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt @@ -150,9 +150,12 @@ private fun CommunityFormScreen( Scaffold( topBar = { - val titleRes = - if (model.isEditing()) R.string.edit_community else R.string.new_community - if (model.isEditing()) { + // Derive the top bar from the parameter, not model.isEditing(), so the + // first composition of the edit screen already shows "Save" before + // loadFrom() runs in its LaunchedEffect. + val isEditing = editing != null + val titleRes = if (isEditing) R.string.edit_community else R.string.new_community + if (isEditing) { ActionTopBar( titleRes = titleRes, postRes = R.string.save,