feat: NIP-72 compliance fixes + standalone Communities screen

Quartz (NIP-72 compliance)
- Fix CommunityDefinition image() builder to use the NIP-72 ImageTag
  (was wrongly pointing at the NIP-23 ImageTag, which prevented the
  builder from attaching `<W>x<H>` dimensions).
- Fix CommunityPostApproval notifyAuthor() to emit a `p` tag for the
  post author as required by NIP-72 (was duplicating the `e` tag).
- Add RelayTag.MARKER_AUTHOR/REQUESTS/APPROVALS constants.

Amethyst - Communities screen
- New top-level Communities screen with FeedFilterSpinner TopNav that
  supports the Mine option (filters to communities authored by the
  logged-in user).
- New Route.Communities + drawer entry.
- New CommunitiesFeedFilter (subclass of DiscoverCommunityFeedFilter
  using defaultCommunitiesFollowList + Mine handling).
- New CommunitiesListFilterAssembler/SubAssembler with dedicated
  filterCommunitiesMine relay query.
- Account.liveCommunitiesFollowLists + settings.defaultCommunitiesFollowList.
- TopNavFilterState.communityRoutes (includes Mine).

Amethyst - Create Community
- Route.NewCommunity + FAB on the Communities screen.
- NewCommunityModel + Material3 form: name, description, image URL,
  rules, moderator pubkeys, relay requests/approvals URLs.
- Signs a kind 34550 CommunityDefinitionEvent and auto-follows it so
  it appears under "Mine".
- Account.sendCommunityDefinition helper.

Misc
- Extracted parseTopFilterOrDefault helper in LocalPreferences to keep
  the existing inner load method under the JVM method-size limit.
This commit is contained in:
Claude
2026-04-20 17:56:18 +00:00
parent f76e4f4c49
commit 30a78c5922
24 changed files with 1154 additions and 15 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,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.CommunitiesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity.NewCommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.LongFormPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
@@ -221,6 +223,8 @@ fun BuildNavigation(
composable<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) }
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,10 @@ sealed class Route {
@Serializable object Polls : Route()
@Serializable object Communities : Route()
@Serializable object NewCommunity : Route()
@Serializable object Badges : Route()
@Serializable object ProfileBadges : Route()
@@ -275,6 +275,18 @@ class TopNavFilterState(
)
}
private val _communityRoutes =
livePeopleListsFlow.transform { peopleLists ->
checkNotInMainThread()
emit(
listOf(
listOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow),
peopleLists,
listOf(muteListFollow),
).flatten().toImmutableList(),
)
}
private val _kind3GlobalPeople =
livePeopleListsFlow.transform { peopleLists ->
checkNotInMainThread()
@@ -302,6 +314,11 @@ class TopNavFilterState(
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow, muteListFollow))
val communityRoutes =
_communityRoutes
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow, muteListFollow))
fun destroy() {
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
}
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.dal.ArticlesFeedFi
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.dal.CommunitiesFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.DiscoverLongFormFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.DiscoverChatFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.DiscoverFollowSetsFeedFilter
@@ -84,6 +85,8 @@ class AccountFeedContentStates(
val badgesFeed = FeedContentState(BadgesFeedFilter(account), scope, LocalCache)
val communitiesList = FeedContentState(CommunitiesFeedFilter(account), scope, LocalCache)
val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache)
val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache)
val shortsFeed = FeedContentState(ShortsFeedFilter(account), scope, LocalCache)
@@ -130,6 +133,8 @@ class AccountFeedContentStates(
badgesFeed.updateFeedWith(newNotes)
communitiesList.updateFeedWith(newNotes)
picturesFeed.updateFeedWith(newNotes)
productsFeed.updateFeedWith(newNotes)
shortsFeed.updateFeedWith(newNotes)
@@ -170,6 +175,8 @@ class AccountFeedContentStates(
badgesFeed.deleteFromFeed(newNotes)
communitiesList.deleteFromFeed(newNotes)
picturesFeed.deleteFromFeed(newNotes)
productsFeed.deleteFromFeed(newNotes)
shortsFeed.deleteFromFeed(newNotes)
@@ -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,163 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@Stable
class NewCommunityModel : ViewModel() {
var account: Account? = null
var isPublishing by mutableStateOf(false)
var name by mutableStateOf("")
var description by mutableStateOf("")
var imageUrl by mutableStateOf("")
var rules by mutableStateOf("")
var moderatorsText by mutableStateOf("")
var relayRequestsUrl by mutableStateOf("")
var relayApprovalsUrl by mutableStateOf("")
fun init(account: Account) {
if (this.account == account) return
this.account = account
}
fun canPost(): Boolean =
!isPublishing &&
name.isNotBlank() &&
description.isNotBlank()
@OptIn(ExperimentalUuidApi::class)
fun publish(
context: Context,
onSuccess: () -> Unit,
onError: (String, String) -> Unit,
) = try {
publishUnsafe(context, onSuccess, onError)
} catch (e: SignerExceptions.ReadOnlyException) {
onError(
stringRes(context, R.string.read_only_user),
stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events),
)
}
@OptIn(ExperimentalUuidApi::class)
private fun publishUnsafe(
context: Context,
onSuccess: () -> Unit,
onError: (String, String) -> Unit,
) {
val myAccount = account ?: return
viewModelScope.launch(Dispatchers.IO) {
isPublishing = true
try {
val moderatorTags = parseModeratorTags(myAccount)
val relayTags = parseRelayTags()
val definition =
myAccount.sendCommunityDefinition(
name = name.trim(),
description = description.trim(),
moderators = moderatorTags,
image = imageUrl.trim().ifBlank { null },
rules = rules.trim().ifBlank { null },
relays = relayTags.ifEmpty { null },
dTag = Uuid.random().toString(),
)
if (definition == null) {
onError(
stringRes(context, R.string.read_only_user),
stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events),
)
return@launch
}
// Also follow the community so it appears on the user's list.
val communityNote = myAccount.cache.getOrCreateAddressableNote(definition.address())
myAccount.follow(communityNote)
reset()
onSuccess()
} finally {
isPublishing = false
}
}
}
private fun parseModeratorTags(account: Account): List<ModeratorTag> {
val hexes =
moderatorsText
.lineSequence()
.map { it.trim() }
.filter { it.length == 64 && it.all { ch -> ch.isDigit() || (ch in 'a'..'f') || (ch in 'A'..'F') } }
.map { it.lowercase() }
.toSet()
val withOwner = hexes + account.signer.pubKey
return withOwner.map { ModeratorTag(it, null, "moderator") }
}
private fun parseRelayTags(): List<RelayTag> {
val tags = mutableListOf<RelayTag>()
relayRequestsUrl.trim().takeIf { it.isNotEmpty() }?.let {
RelayUrlNormalizer.normalizeOrNull(it)?.let { url ->
tags += RelayTag(url, RelayTag.MARKER_REQUESTS)
}
}
relayApprovalsUrl.trim().takeIf { it.isNotEmpty() }?.let {
RelayUrlNormalizer.normalizeOrNull(it)?.let { url ->
tags += RelayTag(url, RelayTag.MARKER_APPROVALS)
}
}
return tags
}
fun reset() {
name = ""
description = ""
imageUrl = ""
rules = ""
moderatorsText = ""
relayRequestsUrl = ""
relayApprovalsUrl = ""
}
}
@@ -0,0 +1,173 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewCommunityScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val model: NewCommunityModel = viewModel()
LaunchedEffect(accountViewModel.account) {
model.init(accountViewModel.account)
}
val context = LocalContext.current
var errorTitle by remember { mutableStateOf<String?>(null) }
var errorBody by remember { mutableStateOf<String?>(null) }
Scaffold(
topBar = {
TopBarWithBackButton(
caption = stringRes(R.string.new_community),
popBack = { nav.popBack() },
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp, vertical = 12.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = model.name,
onValueChange = { model.name = it },
label = { Text(stringRes(R.string.new_community_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = model.description,
onValueChange = { model.description = it },
label = { Text(stringRes(R.string.new_community_description)) },
minLines = 3,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = model.imageUrl,
onValueChange = { model.imageUrl = it },
label = { Text(stringRes(R.string.new_community_image_url)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = model.rules,
onValueChange = { model.rules = it },
label = { Text(stringRes(R.string.new_community_rules)) },
minLines = 3,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = model.moderatorsText,
onValueChange = { model.moderatorsText = it },
label = { Text(stringRes(R.string.new_community_moderators)) },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = model.relayRequestsUrl,
onValueChange = { model.relayRequestsUrl = it },
label = { Text(stringRes(R.string.new_community_relay_requests)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = model.relayApprovalsUrl,
onValueChange = { model.relayApprovalsUrl = it },
label = { Text(stringRes(R.string.new_community_relay_approvals)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
errorTitle?.let { title ->
Text(
text = title,
fontWeight = FontWeight.Bold,
)
errorBody?.let { body ->
Text(text = body)
}
}
Button(
onClick = {
errorTitle = null
errorBody = null
model.publish(
context = context,
onSuccess = { nav.popBack() },
onError = { title, body ->
errorTitle = title
errorBody = body
},
)
},
enabled = model.canPost(),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 10.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(text = stringRes(R.string.new_community_publish))
}
}
}
}
+11
View File
@@ -420,6 +420,17 @@
<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="new_community_name">Community name</string>
<string name="new_community_description">Description</string>
<string name="new_community_image_url">Cover image URL (optional)</string>
<string name="new_community_rules">Rules (optional)</string>
<string name="new_community_moderators">Moderators (one pubkey per line, optional)</string>
<string name="new_community_relay_requests">Relay for requests (optional)</string>
<string name="new_community_relay_approvals">Relay for approvals (optional)</string>
<string name="new_community_publish">Publish</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 }