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:
@@ -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)
|
||||
}
|
||||
|
||||
+3
@@ -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) }
|
||||
|
||||
+9
@@ -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()
|
||||
|
||||
+5
-1
@@ -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 -> {
|
||||
|
||||
+23
-11
@@ -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
|
||||
|
||||
+7
@@ -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)
|
||||
|
||||
+180
@@ -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()
|
||||
}
|
||||
}
|
||||
+70
@@ -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,
|
||||
)
|
||||
}
|
||||
+53
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+125
@@ -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)
|
||||
}
|
||||
+50
@@ -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() }
|
||||
}
|
||||
+48
@@ -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)
|
||||
}
|
||||
+104
@@ -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() }
|
||||
}
|
||||
}
|
||||
+51
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+316
@@ -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()
|
||||
}
|
||||
}
|
||||
+641
@@ -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) },
|
||||
)
|
||||
}
|
||||
+30
-15
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
+2
-3
@@ -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))
|
||||
|
||||
+6
-2
@@ -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))
|
||||
|
||||
|
||||
+4
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user