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..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 @@ -93,6 +93,9 @@ 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.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 import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen @@ -221,6 +224,9 @@ fun BuildNavigation( composable { DiscoverScreen(accountViewModel, nav) } composableArgs { NotificationScreen(it.scrollToEventId, accountViewModel, nav) } 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/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..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 @@ -45,6 +45,22 @@ sealed class Route { @Serializable object Polls : Route() + @Serializable object Communities : 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 d008ad627..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 @@ -275,6 +276,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 +315,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}" } } @@ -364,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/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 1dfbda89f..65374a3bf 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 @@ -86,6 +87,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) @@ -145,6 +148,8 @@ class AccountFeedContentStates( badgesFeed.updateFeedWith(newNotes) + communitiesList.updateFeedWith(newNotes) + picturesFeed.updateFeedWith(newNotes) productsFeed.updateFeedWith(newNotes) shortsFeed.updateFeedWith(newNotes) @@ -185,6 +190,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..09c61bed8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt @@ -0,0 +1,316 @@ +/* + * 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.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.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 +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 + + // 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) + 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 + } + + /** + * 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) { + 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 = + !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 uploadedUrl = + if (multiOrchestrator != null) { + uploadImageIfAny(context, myAccount, onError) ?: return@launch + } else { + null + } + + val imageUrl = uploadedUrl ?: existingImageUrl + + 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 dTag = existingDTag ?: Uuid.random().toString() + + val definition = + myAccount.sendCommunityDefinition( + name = name.trim(), + description = description.trim(), + moderators = moderatorTags, + image = imageUrl, + rules = rules.trim().ifBlank { null }, + relays = relayTags.ifEmpty { null }, + dTag = dTag, + ) + + 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 + } + + // 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) + + reset() + onSuccess() + } finally { + isPublishing = false + } + } + } + + private suspend fun uploadImageIfAny( + context: Context, + myAccount: Account, + onError: (String, String) -> Unit, + ): String? { + val orch = multiOrchestrator ?: return null + val serverToUse = selectedServer ?: defaultServer() + + 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, + ) + + 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 + } + + 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 uploaded.url + } + + fun reset() { + name = "" + description = "" + rules = "" + existingImageUrl = null + existingDTag = null + 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 new file mode 100644 index 000000000..456a577c3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt @@ -0,0 +1,641 @@ +/* + * 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.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.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.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 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 +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.nip01Core.core.Address +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag +import kotlinx.collections.immutable.persistentListOf + +@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 + + LaunchedEffect(accountViewModel.account) { + 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) } + + if (wantsToPickImage) { + GallerySelect( + onImageUri = { uris -> + wantsToPickImage = false + model.setPickedMedia( + if (uris.isNotEmpty()) persistentListOf(uris.first()) else persistentListOf(), + ) + }, + ) + } + + Scaffold( + topBar = { + // 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, + 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( + 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), + ) { + 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, +) { + 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, + ) + } + } + } + + !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, + ) + } + } +} + +@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/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..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.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() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 7362efe51..071adb861 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -421,6 +421,26 @@ Open Closed Badges + Communities + New Community + Edit Community + Community name + Description + Rules (optional) + 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 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 }