feat: NIP-72 compliance fixes + standalone Communities screen
Quartz (NIP-72 compliance) - Fix CommunityDefinition image() builder to use the NIP-72 ImageTag (was wrongly pointing at the NIP-23 ImageTag, which prevented the builder from attaching `<W>x<H>` dimensions). - Fix CommunityPostApproval notifyAuthor() to emit a `p` tag for the post author as required by NIP-72 (was duplicating the `e` tag). - Add RelayTag.MARKER_AUTHOR/REQUESTS/APPROVALS constants. Amethyst - Communities screen - New top-level Communities screen with FeedFilterSpinner TopNav that supports the Mine option (filters to communities authored by the logged-in user). - New Route.Communities + drawer entry. - New CommunitiesFeedFilter (subclass of DiscoverCommunityFeedFilter using defaultCommunitiesFollowList + Mine handling). - New CommunitiesListFilterAssembler/SubAssembler with dedicated filterCommunitiesMine relay query. - Account.liveCommunitiesFollowLists + settings.defaultCommunitiesFollowList. - TopNavFilterState.communityRoutes (includes Mine). Amethyst - Create Community - Route.NewCommunity + FAB on the Communities screen. - NewCommunityModel + Material3 form: name, description, image URL, rules, moderator pubkeys, relay requests/approvals URLs. - Signs a kind 34550 CommunityDefinitionEvent and auto-follows it so it appears under "Mine". - Account.sendCommunityDefinition helper. Misc - Extracted parseTopFilterOrDefault helper in LocalPreferences to keep the existing inner load method under the JVM method-size limit.
This commit is contained in:
@@ -547,17 +547,17 @@ object LocalPreferences {
|
|||||||
|
|
||||||
Log.d("LocalPreferences") { "Load account from file $npub - before parsing events" }
|
Log.d("LocalPreferences") { "Load account from file $npub - before parsing events" }
|
||||||
|
|
||||||
val defaultHomeFollowList = async { parseOrNull<TopFilter>(defaultHomeFollowListStr) ?: TopFilter.AllFollows }
|
val defaultHomeFollowList = async { parseTopFilterOrDefault(defaultHomeFollowListStr, TopFilter.AllFollows) }
|
||||||
val defaultStoriesFollowList = async { parseOrNull<TopFilter>(defaultStoriesFollowListStr) ?: TopFilter.Global }
|
val defaultStoriesFollowList = async { parseTopFilterOrDefault(defaultStoriesFollowListStr, TopFilter.Global) }
|
||||||
val defaultNotificationFollowList = async { parseOrNull<TopFilter>(defaultNotificationFollowListStr) ?: TopFilter.Global }
|
val defaultNotificationFollowList = async { parseTopFilterOrDefault(defaultNotificationFollowListStr, TopFilter.Global) }
|
||||||
val defaultDiscoveryFollowList = async { parseOrNull<TopFilter>(defaultDiscoveryFollowListStr) ?: TopFilter.Global }
|
val defaultDiscoveryFollowList = async { parseTopFilterOrDefault(defaultDiscoveryFollowListStr, TopFilter.Global) }
|
||||||
|
|
||||||
val defaultPollsFollowList = async { parseOrNull<TopFilter>(defaultPollsFollowListStr) ?: TopFilter.Global }
|
val defaultPollsFollowList = async { parseTopFilterOrDefault(defaultPollsFollowListStr, TopFilter.Global) }
|
||||||
val defaultPicturesFollowList = async { parseOrNull<TopFilter>(defaultPicturesFollowListStr) ?: TopFilter.Global }
|
val defaultPicturesFollowList = async { parseTopFilterOrDefault(defaultPicturesFollowListStr, TopFilter.Global) }
|
||||||
val defaultProductsFollowList = async { parseOrNull<TopFilter>(defaultProductsFollowListStr) ?: TopFilter.AroundMe }
|
val defaultProductsFollowList = async { parseTopFilterOrDefault(defaultProductsFollowListStr, TopFilter.AroundMe) }
|
||||||
val defaultShortsFollowList = async { parseOrNull<TopFilter>(defaultShortsFollowListStr) ?: TopFilter.Global }
|
val defaultShortsFollowList = async { parseTopFilterOrDefault(defaultShortsFollowListStr, TopFilter.Global) }
|
||||||
val defaultLongsFollowList = async { parseOrNull<TopFilter>(defaultLongsFollowListStr) ?: TopFilter.Global }
|
val defaultLongsFollowList = async { parseTopFilterOrDefault(defaultLongsFollowListStr, TopFilter.Global) }
|
||||||
val defaultArticlesFollowList = async { parseOrNull<TopFilter>(defaultArticlesFollowListStr) ?: TopFilter.AllFollows }
|
val defaultArticlesFollowList = async { parseTopFilterOrDefault(defaultArticlesFollowListStr, TopFilter.AllFollows) }
|
||||||
|
|
||||||
val nwcWalletsLoaded =
|
val nwcWalletsLoaded =
|
||||||
async {
|
async {
|
||||||
@@ -672,6 +672,20 @@ object LocalPreferences {
|
|||||||
return result
|
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? {
|
private inline fun <reified T : Any> parseOrNull(value: String?): T? {
|
||||||
if (value.isNullOrEmpty() || value == "null") {
|
if (value.isNullOrEmpty() || value == "null") {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -223,6 +223,8 @@ import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
|||||||
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
|
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
|
||||||
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
|
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
|
||||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
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.poll.PollEvent
|
||||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
|
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
|
||||||
@@ -481,6 +483,9 @@ class Account(
|
|||||||
val liveBadgesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultBadgesFollowList)
|
val liveBadgesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultBadgesFollowList)
|
||||||
val liveBadgesFollowListsPerRelay = OutboxLoaderState(liveBadgesFollowLists, cache, scope).flow
|
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()
|
override fun isWriteable(): Boolean = settings.isWriteable()
|
||||||
|
|
||||||
suspend fun updateWarnReports(warnReports: Boolean): Boolean {
|
suspend fun updateWarnReports(warnReports: Boolean): Boolean {
|
||||||
@@ -1161,6 +1166,34 @@ class Account(
|
|||||||
client.publish(signedEvent, relays)
|
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> {
|
private fun loadCurrentAcceptedBadges(): List<AcceptedBadge> {
|
||||||
val newNote = cache.getAddressableNoteIfExists(ProfileBadgesEvent.createAddress(signer.pubKey))
|
val newNote = cache.getAddressableNoteIfExists(ProfileBadgesEvent.createAddress(signer.pubKey))
|
||||||
val newEvent = newNote?.event as? ProfileBadgesEvent
|
val newEvent = newNote?.event as? ProfileBadgesEvent
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ class AccountSettings(
|
|||||||
val defaultLongsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
val defaultLongsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||||
val defaultArticlesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
|
val defaultArticlesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
|
||||||
val defaultBadgesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Mine),
|
val defaultBadgesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Mine),
|
||||||
|
val defaultCommunitiesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
|
||||||
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
|
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
|
||||||
val defaultNwcWalletId: MutableStateFlow<String?> = MutableStateFlow(null),
|
val defaultNwcWalletId: MutableStateFlow<String?> = MutableStateFlow(null),
|
||||||
var hideDeleteRequestDialog: Boolean = false,
|
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) {
|
fun changeDefaultPicturesFollowList(name: FeedDefinition) {
|
||||||
changeDefaultPicturesFollowList(name.code)
|
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.chats.rooms.datasource.ChatroomListFilterAssembler
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessFilterAssembler
|
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.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.discover.datasource.DiscoveryFilterAssembler
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler
|
||||||
@@ -101,6 +102,7 @@ class RelaySubscriptionsCoordinator(
|
|||||||
val articles = ArticlesFilterAssembler(client)
|
val articles = ArticlesFilterAssembler(client)
|
||||||
val badges = BadgesFilterAssembler(client)
|
val badges = BadgesFilterAssembler(client)
|
||||||
val profileBadges = ProfileBadgesFilterAssembler(client)
|
val profileBadges = ProfileBadgesFilterAssembler(client)
|
||||||
|
val communitiesList = CommunitiesListFilterAssembler(client)
|
||||||
|
|
||||||
// active when sending zaps via NWC
|
// active when sending zaps via NWC
|
||||||
val nwc = NWCPaymentFilterAssembler(client)
|
val nwc = NWCPaymentFilterAssembler(client)
|
||||||
@@ -120,6 +122,7 @@ class RelaySubscriptionsCoordinator(
|
|||||||
articles,
|
articles,
|
||||||
badges,
|
badges,
|
||||||
profileBadges,
|
profileBadges,
|
||||||
|
communitiesList,
|
||||||
channelFinder,
|
channelFinder,
|
||||||
eventFinder,
|
eventFinder,
|
||||||
userFinder,
|
userFinder,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ object ScrollStateKeys {
|
|||||||
const val POLLS_OPEN = "PollsOpenFeed"
|
const val POLLS_OPEN = "PollsOpenFeed"
|
||||||
const val POLLS_CLOSED = "PollsClosedFeed"
|
const val POLLS_CLOSED = "PollsClosedFeed"
|
||||||
const val BADGES_SCREEN = "BadgesFeed"
|
const val BADGES_SCREEN = "BadgesFeed"
|
||||||
|
const val COMMUNITIES_LIST = "CommunitiesListFeed"
|
||||||
const val PICTURES_SCREEN = "PicturesFeed"
|
const val PICTURES_SCREEN = "PicturesFeed"
|
||||||
const val PRODUCTS_SCREEN = "ProductsFeed"
|
const val PRODUCTS_SCREEN = "ProductsFeed"
|
||||||
const val SHORTS_SCREEN = "ShortsFeed"
|
const val SHORTS_SCREEN = "ShortsFeed"
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen
|
|||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen
|
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.CommunityScreen
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.CommunitiesScreen
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity.NewCommunityScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.LongFormPostScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.LongFormPostScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
|
||||||
@@ -221,6 +223,8 @@ fun BuildNavigation(
|
|||||||
composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
|
composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
|
||||||
composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
|
composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
|
||||||
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
|
||||||
|
composableFromEnd<Route.Communities> { CommunitiesScreen(accountViewModel, nav) }
|
||||||
|
composableFromEnd<Route.NewCommunity> { NewCommunityScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.Badges> { BadgesScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Badges> { BadgesScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
|
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
|
||||||
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, 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.CollectionsBookmark
|
||||||
import androidx.compose.material.icons.outlined.Drafts
|
import androidx.compose.material.icons.outlined.Drafts
|
||||||
import androidx.compose.material.icons.outlined.GroupAdd
|
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.Language
|
||||||
import androidx.compose.material.icons.outlined.MilitaryTech
|
import androidx.compose.material.icons.outlined.MilitaryTech
|
||||||
import androidx.compose.material.icons.outlined.Photo
|
import androidx.compose.material.icons.outlined.Photo
|
||||||
@@ -619,6 +620,14 @@ fun ListContent(
|
|||||||
route = Route.Badges,
|
route = Route.Badges,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
NavigationRow(
|
||||||
|
title = R.string.communities,
|
||||||
|
icon = Icons.Outlined.Groups,
|
||||||
|
tint = MaterialTheme.colorScheme.onBackground,
|
||||||
|
nav = nav,
|
||||||
|
route = Route.Communities,
|
||||||
|
)
|
||||||
|
|
||||||
NavigationRow(
|
NavigationRow(
|
||||||
title = R.string.discover_marketplace,
|
title = R.string.discover_marketplace,
|
||||||
icon = Icons.Outlined.Storefront,
|
icon = Icons.Outlined.Storefront,
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ sealed class Route {
|
|||||||
|
|
||||||
@Serializable object Polls : Route()
|
@Serializable object Polls : Route()
|
||||||
|
|
||||||
|
@Serializable object Communities : Route()
|
||||||
|
|
||||||
|
@Serializable object NewCommunity : Route()
|
||||||
|
|
||||||
@Serializable object Badges : Route()
|
@Serializable object Badges : Route()
|
||||||
|
|
||||||
@Serializable object ProfileBadges : Route()
|
@Serializable object ProfileBadges : Route()
|
||||||
|
|||||||
@@ -275,6 +275,18 @@ class TopNavFilterState(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val _communityRoutes =
|
||||||
|
livePeopleListsFlow.transform { peopleLists ->
|
||||||
|
checkNotInMainThread()
|
||||||
|
emit(
|
||||||
|
listOf(
|
||||||
|
listOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow),
|
||||||
|
peopleLists,
|
||||||
|
listOf(muteListFollow),
|
||||||
|
).flatten().toImmutableList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private val _kind3GlobalPeople =
|
private val _kind3GlobalPeople =
|
||||||
livePeopleListsFlow.transform { peopleLists ->
|
livePeopleListsFlow.transform { peopleLists ->
|
||||||
checkNotInMainThread()
|
checkNotInMainThread()
|
||||||
@@ -302,6 +314,11 @@ class TopNavFilterState(
|
|||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow, muteListFollow))
|
.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() {
|
fun destroy() {
|
||||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||||
}
|
}
|
||||||
|
|||||||
+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.badges.dal.BadgesFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter
|
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.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.nip23LongForm.DiscoverLongFormFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.DiscoverChatFeedFilter
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.DiscoverChatFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.DiscoverFollowSetsFeedFilter
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.DiscoverFollowSetsFeedFilter
|
||||||
@@ -84,6 +85,8 @@ class AccountFeedContentStates(
|
|||||||
|
|
||||||
val badgesFeed = FeedContentState(BadgesFeedFilter(account), scope, LocalCache)
|
val badgesFeed = FeedContentState(BadgesFeedFilter(account), scope, LocalCache)
|
||||||
|
|
||||||
|
val communitiesList = FeedContentState(CommunitiesFeedFilter(account), scope, LocalCache)
|
||||||
|
|
||||||
val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache)
|
val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache)
|
||||||
val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache)
|
val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache)
|
||||||
val shortsFeed = FeedContentState(ShortsFeedFilter(account), scope, LocalCache)
|
val shortsFeed = FeedContentState(ShortsFeedFilter(account), scope, LocalCache)
|
||||||
@@ -130,6 +133,8 @@ class AccountFeedContentStates(
|
|||||||
|
|
||||||
badgesFeed.updateFeedWith(newNotes)
|
badgesFeed.updateFeedWith(newNotes)
|
||||||
|
|
||||||
|
communitiesList.updateFeedWith(newNotes)
|
||||||
|
|
||||||
picturesFeed.updateFeedWith(newNotes)
|
picturesFeed.updateFeedWith(newNotes)
|
||||||
productsFeed.updateFeedWith(newNotes)
|
productsFeed.updateFeedWith(newNotes)
|
||||||
shortsFeed.updateFeedWith(newNotes)
|
shortsFeed.updateFeedWith(newNotes)
|
||||||
@@ -170,6 +175,8 @@ class AccountFeedContentStates(
|
|||||||
|
|
||||||
badgesFeed.deleteFromFeed(newNotes)
|
badgesFeed.deleteFromFeed(newNotes)
|
||||||
|
|
||||||
|
communitiesList.deleteFromFeed(newNotes)
|
||||||
|
|
||||||
picturesFeed.deleteFromFeed(newNotes)
|
picturesFeed.deleteFromFeed(newNotes)
|
||||||
productsFeed.deleteFromFeed(newNotes)
|
productsFeed.deleteFromFeed(newNotes)
|
||||||
shortsFeed.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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||||
|
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
|
||||||
|
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
class NewCommunityModel : ViewModel() {
|
||||||
|
var account: Account? = null
|
||||||
|
|
||||||
|
var isPublishing by mutableStateOf(false)
|
||||||
|
|
||||||
|
var name by mutableStateOf("")
|
||||||
|
var description by mutableStateOf("")
|
||||||
|
var imageUrl by mutableStateOf("")
|
||||||
|
var rules by mutableStateOf("")
|
||||||
|
var moderatorsText by mutableStateOf("")
|
||||||
|
var relayRequestsUrl by mutableStateOf("")
|
||||||
|
var relayApprovalsUrl by mutableStateOf("")
|
||||||
|
|
||||||
|
fun init(account: Account) {
|
||||||
|
if (this.account == account) return
|
||||||
|
this.account = account
|
||||||
|
}
|
||||||
|
|
||||||
|
fun canPost(): Boolean =
|
||||||
|
!isPublishing &&
|
||||||
|
name.isNotBlank() &&
|
||||||
|
description.isNotBlank()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
|
fun publish(
|
||||||
|
context: Context,
|
||||||
|
onSuccess: () -> Unit,
|
||||||
|
onError: (String, String) -> Unit,
|
||||||
|
) = try {
|
||||||
|
publishUnsafe(context, onSuccess, onError)
|
||||||
|
} catch (e: SignerExceptions.ReadOnlyException) {
|
||||||
|
onError(
|
||||||
|
stringRes(context, R.string.read_only_user),
|
||||||
|
stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
|
private fun publishUnsafe(
|
||||||
|
context: Context,
|
||||||
|
onSuccess: () -> Unit,
|
||||||
|
onError: (String, String) -> Unit,
|
||||||
|
) {
|
||||||
|
val myAccount = account ?: return
|
||||||
|
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
isPublishing = true
|
||||||
|
try {
|
||||||
|
val moderatorTags = parseModeratorTags(myAccount)
|
||||||
|
val relayTags = parseRelayTags()
|
||||||
|
|
||||||
|
val definition =
|
||||||
|
myAccount.sendCommunityDefinition(
|
||||||
|
name = name.trim(),
|
||||||
|
description = description.trim(),
|
||||||
|
moderators = moderatorTags,
|
||||||
|
image = imageUrl.trim().ifBlank { null },
|
||||||
|
rules = rules.trim().ifBlank { null },
|
||||||
|
relays = relayTags.ifEmpty { null },
|
||||||
|
dTag = Uuid.random().toString(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (definition == null) {
|
||||||
|
onError(
|
||||||
|
stringRes(context, R.string.read_only_user),
|
||||||
|
stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events),
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also follow the community so it appears on the user's list.
|
||||||
|
val communityNote = myAccount.cache.getOrCreateAddressableNote(definition.address())
|
||||||
|
myAccount.follow(communityNote)
|
||||||
|
|
||||||
|
reset()
|
||||||
|
onSuccess()
|
||||||
|
} finally {
|
||||||
|
isPublishing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseModeratorTags(account: Account): List<ModeratorTag> {
|
||||||
|
val hexes =
|
||||||
|
moderatorsText
|
||||||
|
.lineSequence()
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.length == 64 && it.all { ch -> ch.isDigit() || (ch in 'a'..'f') || (ch in 'A'..'F') } }
|
||||||
|
.map { it.lowercase() }
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
val withOwner = hexes + account.signer.pubKey
|
||||||
|
|
||||||
|
return withOwner.map { ModeratorTag(it, null, "moderator") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRelayTags(): List<RelayTag> {
|
||||||
|
val tags = mutableListOf<RelayTag>()
|
||||||
|
relayRequestsUrl.trim().takeIf { it.isNotEmpty() }?.let {
|
||||||
|
RelayUrlNormalizer.normalizeOrNull(it)?.let { url ->
|
||||||
|
tags += RelayTag(url, RelayTag.MARKER_REQUESTS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
relayApprovalsUrl.trim().takeIf { it.isNotEmpty() }?.let {
|
||||||
|
RelayUrlNormalizer.normalizeOrNull(it)?.let { url ->
|
||||||
|
tags += RelayTag(url, RelayTag.MARKER_APPROVALS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
name = ""
|
||||||
|
description = ""
|
||||||
|
imageUrl = ""
|
||||||
|
rules = ""
|
||||||
|
moderatorsText = ""
|
||||||
|
relayRequestsUrl = ""
|
||||||
|
relayApprovalsUrl = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun NewCommunityScreen(
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: INav,
|
||||||
|
) {
|
||||||
|
val model: NewCommunityModel = viewModel()
|
||||||
|
|
||||||
|
LaunchedEffect(accountViewModel.account) {
|
||||||
|
model.init(accountViewModel.account)
|
||||||
|
}
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
var errorTitle by remember { mutableStateOf<String?>(null) }
|
||||||
|
var errorBody by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopBarWithBackButton(
|
||||||
|
caption = stringRes(R.string.new_community),
|
||||||
|
popBack = { nav.popBack() },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = model.name,
|
||||||
|
onValueChange = { model.name = it },
|
||||||
|
label = { Text(stringRes(R.string.new_community_name)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = model.description,
|
||||||
|
onValueChange = { model.description = it },
|
||||||
|
label = { Text(stringRes(R.string.new_community_description)) },
|
||||||
|
minLines = 3,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = model.imageUrl,
|
||||||
|
onValueChange = { model.imageUrl = it },
|
||||||
|
label = { Text(stringRes(R.string.new_community_image_url)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = model.rules,
|
||||||
|
onValueChange = { model.rules = it },
|
||||||
|
label = { Text(stringRes(R.string.new_community_rules)) },
|
||||||
|
minLines = 3,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = model.moderatorsText,
|
||||||
|
onValueChange = { model.moderatorsText = it },
|
||||||
|
label = { Text(stringRes(R.string.new_community_moderators)) },
|
||||||
|
minLines = 2,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = model.relayRequestsUrl,
|
||||||
|
onValueChange = { model.relayRequestsUrl = it },
|
||||||
|
label = { Text(stringRes(R.string.new_community_relay_requests)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = model.relayApprovalsUrl,
|
||||||
|
onValueChange = { model.relayApprovalsUrl = it },
|
||||||
|
label = { Text(stringRes(R.string.new_community_relay_approvals)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
errorTitle?.let { title ->
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
errorBody?.let { body ->
|
||||||
|
Text(text = body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
errorTitle = null
|
||||||
|
errorBody = null
|
||||||
|
model.publish(
|
||||||
|
context = context,
|
||||||
|
onSuccess = { nav.popBack() },
|
||||||
|
onError = { title, body ->
|
||||||
|
errorTitle = title
|
||||||
|
errorBody = body
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = model.canPost(),
|
||||||
|
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(text = stringRes(R.string.new_community_publish))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -420,6 +420,17 @@
|
|||||||
<string name="open_polls">Open</string>
|
<string name="open_polls">Open</string>
|
||||||
<string name="closed_polls">Closed</string>
|
<string name="closed_polls">Closed</string>
|
||||||
<string name="badges">Badges</string>
|
<string name="badges">Badges</string>
|
||||||
|
<string name="communities">Communities</string>
|
||||||
|
<string name="new_community">New Community</string>
|
||||||
|
<string name="new_community_name">Community name</string>
|
||||||
|
<string name="new_community_description">Description</string>
|
||||||
|
<string name="new_community_image_url">Cover image URL (optional)</string>
|
||||||
|
<string name="new_community_rules">Rules (optional)</string>
|
||||||
|
<string name="new_community_moderators">Moderators (one pubkey per line, optional)</string>
|
||||||
|
<string name="new_community_relay_requests">Relay for requests (optional)</string>
|
||||||
|
<string name="new_community_relay_approvals">Relay for approvals (optional)</string>
|
||||||
|
<string name="new_community_publish">Publish</string>
|
||||||
|
<string name="new_community_empty">No communities match this filter.</string>
|
||||||
<string name="received_badges">Received</string>
|
<string name="received_badges">Received</string>
|
||||||
<string name="my_badges">Mine</string>
|
<string name="my_badges">Mine</string>
|
||||||
<string name="awarded_badges">Awarded</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.hints.EventHintBundle
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.toATag
|
import com.vitorpamplona.quartz.nip01Core.tags.aTag.toATag
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.events.toETagArray
|
import com.vitorpamplona.quartz.nip01Core.tags.events.toETagArray
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||||
|
|
||||||
fun TagArrayBuilder<CommunityPostApprovalEvent>.community(event: EventHintBundle<CommunityDefinitionEvent>) = add(event.toATag().toATagArray())
|
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>) {
|
fun TagArrayBuilder<CommunityPostApprovalEvent>.notifyAuthor(event: EventHintBundle<Event>) = add(PTag.assemble(event.event.pubKey, event.authorHomeRelay))
|
||||||
add(event.toETagArray())
|
|
||||||
}
|
|
||||||
|
|||||||
+6
-2
@@ -21,18 +21,22 @@
|
|||||||
package com.vitorpamplona.quartz.nip72ModCommunities.definition
|
package com.vitorpamplona.quartz.nip72ModCommunities.definition
|
||||||
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
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.DescriptionTag
|
||||||
|
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ImageTag
|
||||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
|
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
|
||||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.NameTag
|
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.NameTag
|
||||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag
|
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag
|
||||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RulesTag
|
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>.name(name: String) = addUnique(NameTag.assemble(name))
|
||||||
|
|
||||||
fun TagArrayBuilder<CommunityDefinitionEvent>.description(description: String) = addUnique(DescriptionTag.assemble(description))
|
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))
|
fun TagArrayBuilder<CommunityDefinitionEvent>.rules(rules: String) = addUnique(RulesTag.assemble(rules))
|
||||||
|
|
||||||
|
|||||||
+4
@@ -35,6 +35,10 @@ class RelayTag(
|
|||||||
companion object {
|
companion object {
|
||||||
const val TAG_NAME = "relay"
|
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? {
|
fun parse(tag: Array<String>): RelayTag? {
|
||||||
ensure(tag.has(1)) { return null }
|
ensure(tag.has(1)) { return null }
|
||||||
ensure(tag[0] == TAG_NAME) { return null }
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
|||||||
Reference in New Issue
Block a user