Merge pull request #2468 from vitorpamplona/claude/nip72-communities-screen-TwEMq

Add community creation and management UI with NIP-72 support
This commit is contained in:
Vitor Pamplona
2026-04-20 17:43:49 -04:00
committed by GitHub
28 changed files with 1883 additions and 43 deletions
@@ -547,17 +547,17 @@ object LocalPreferences {
Log.d("LocalPreferences") { "Load account from file $npub - before parsing events" }
val defaultHomeFollowList = async { parseOrNull<TopFilter>(defaultHomeFollowListStr) ?: TopFilter.AllFollows }
val defaultStoriesFollowList = async { parseOrNull<TopFilter>(defaultStoriesFollowListStr) ?: TopFilter.Global }
val defaultNotificationFollowList = async { parseOrNull<TopFilter>(defaultNotificationFollowListStr) ?: TopFilter.Global }
val defaultDiscoveryFollowList = async { parseOrNull<TopFilter>(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<TopFilter>(defaultPollsFollowListStr) ?: TopFilter.Global }
val defaultPicturesFollowList = async { parseOrNull<TopFilter>(defaultPicturesFollowListStr) ?: TopFilter.Global }
val defaultProductsFollowList = async { parseOrNull<TopFilter>(defaultProductsFollowListStr) ?: TopFilter.AroundMe }
val defaultShortsFollowList = async { parseOrNull<TopFilter>(defaultShortsFollowListStr) ?: TopFilter.Global }
val defaultLongsFollowList = async { parseOrNull<TopFilter>(defaultLongsFollowListStr) ?: TopFilter.Global }
val defaultArticlesFollowList = async { parseOrNull<TopFilter>(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<TopFilter>(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 <reified T : Any> parseOrNull(value: String?): T? {
if (value.isNullOrEmpty() || value == "null") {
return null
@@ -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<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultBadgesFollowList)
val liveBadgesFollowListsPerRelay = OutboxLoaderState(liveBadgesFollowLists, cache, scope).flow
val liveCommunitiesFollowLists: StateFlow<IFeedTopNavFilter> = 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<ModeratorTag>,
image: String? = null,
rules: String? = null,
relays: List<RelayTag>? = 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<AcceptedBadge> {
val newNote = cache.getAddressableNoteIfExists(ProfileBadgesEvent.createAddress(signer.pubKey))
val newEvent = newNote?.event as? ProfileBadgesEvent
@@ -191,6 +191,7 @@ class AccountSettings(
val defaultLongsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultArticlesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultBadgesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Mine),
val defaultCommunitiesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
val defaultNwcWalletId: MutableStateFlow<String?> = 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)
}
@@ -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,
@@ -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"
@@ -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<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
composableFromEnd<Route.Communities> { CommunitiesScreen(accountViewModel, nav) }
composableFromEnd<Route.NewCommunity> { NewCommunityScreen(accountViewModel, nav) }
composableFromEndArgs<Route.EditCommunity> { EditCommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEnd<Route.Badges> { BadgesScreen(accountViewModel, nav) }
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
@@ -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,
@@ -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()
@@ -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 -> {
@@ -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"
}
@@ -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,
@@ -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
@@ -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)
@@ -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()
}
}
@@ -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,
)
}
@@ -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,
)
}
}
@@ -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<Note> {
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<Note>): Set<Note> = innerApplyFilter(newItems)
override fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
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<NormalizedRelayUrl> = emptyList(),
) = aTag != null && aTag.kind == CommunityDefinitionEvent.KIND && params.match(aTag, comingFrom)
}
@@ -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<CommunitiesListQueryState>() {
val group =
listOf(
CommunitiesListSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -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)
}
@@ -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<CommunitiesListQueryState>,
) : PerUserAndFollowListEoseManager<CommunitiesListQueryState, TopFilter>(client, allKeys) {
override fun updateFilter(
key: CommunitiesListQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
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<User, List<Job>>()
@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() }
}
}
@@ -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<NormalizedRelayUrl>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
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,
),
)
}
}
@@ -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<String?>(null)
// Image upload state - mirrors NewBadgeModel.
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
var selectedServer by mutableStateOf<ServerName?>(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<User>()
val relays = mutableStateListOf<CommunityRelayEntry>()
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<SelectedMedia>) {
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()
}
}
@@ -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) },
)
}
@@ -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<CommunityDefinitionEvent>(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()
+20
View File
@@ -421,6 +421,26 @@
<string name="open_polls">Open</string>
<string name="closed_polls">Closed</string>
<string name="badges">Badges</string>
<string name="communities">Communities</string>
<string name="new_community">New Community</string>
<string name="edit_community">Edit Community</string>
<string name="new_community_name">Community name</string>
<string name="new_community_description">Description</string>
<string name="new_community_rules">Rules (optional)</string>
<string name="new_community_pick_cover">Add a cover image</string>
<string name="new_community_pick_cover_hint">Tap to pick a photo — it will be uploaded to your media server.</string>
<string name="new_community_moderators_section">Moderators</string>
<string name="new_community_moderators_hint">Moderators can approve posts. You are always a moderator.</string>
<string name="new_community_add_moderator">Add a moderator</string>
<string name="new_community_add_moderator_placeholder">Name, npub, or NIP-05</string>
<string name="new_community_owner">Owner</string>
<string name="new_community_relays_section">Relays</string>
<string name="new_community_relays_hint">Pick relays that will host requests, approvals, or the community author\'s metadata.</string>
<string name="new_community_relay_marker_none">Any</string>
<string name="new_community_relay_marker_author">Author</string>
<string name="new_community_relay_marker_requests">Requests</string>
<string name="new_community_relay_marker_approvals">Approvals</string>
<string name="new_community_empty">No communities match this filter.</string>
<string name="received_badges">Received</string>
<string name="my_badges">Mine</string>
<string name="awarded_badges">Awarded</string>
@@ -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<CommunityPostApprovalEvent>.community(event: EventHintBundle<CommunityDefinitionEvent>) = add(event.toATag().toATagArray())
@@ -37,6 +38,4 @@ fun TagArrayBuilder<CommunityPostApprovalEvent>.approved(event: EventHintBundle<
}
}
fun TagArrayBuilder<CommunityPostApprovalEvent>.notifyAuthor(event: EventHintBundle<Event>) {
add(event.toETagArray())
}
fun TagArrayBuilder<CommunityPostApprovalEvent>.notifyAuthor(event: EventHintBundle<Event>) = add(PTag.assemble(event.event.pubKey, event.authorHomeRelay))
@@ -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<CommunityDefinitionEvent>.name(name: String) = addUnique(NameTag.assemble(name))
fun TagArrayBuilder<CommunityDefinitionEvent>.description(description: String) = addUnique(DescriptionTag.assemble(description))
fun TagArrayBuilder<CommunityDefinitionEvent>.image(webUrl: String) = addUnique(ImageTag.assemble(webUrl))
fun TagArrayBuilder<CommunityDefinitionEvent>.image(
webUrl: String,
dimensions: DimensionTag? = null,
) = addUnique(ImageTag.assemble(webUrl, dimensions))
fun TagArrayBuilder<CommunityDefinitionEvent>.rules(rules: String) = addUnique(RulesTag.assemble(rules))
@@ -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<String>): RelayTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }