- Upgrades NIP-72 communities to NIP-22 comments

- Adds moderation queue feed
- Adds in-app approval procedures
- Adds drafts for community posts
- Redesigns About us section of communities.
This commit is contained in:
Vitor Pamplona
2025-08-26 20:10:04 -04:00
parent fd752a4997
commit aa80d1b84a
42 changed files with 1509 additions and 382 deletions
@@ -186,6 +186,8 @@ import com.vitorpamplona.quartz.nip68Picture.pictureIMeta
import com.vitorpamplona.quartz.nip71Video.VideoMeta
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.nip90Dvms.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nip92IMeta.imetas
@@ -863,6 +865,24 @@ class Account(
suspend fun unfollowGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.unfollow(geohash))
suspend fun approveCommunityPost(
post: Note,
community: AddressableNote,
) {
val commEvent = community.event as? CommunityDefinitionEvent ?: return
val postHint = post.toEventHint<Event>() ?: return
val communityHint = community.toEventHint<CommunityDefinitionEvent>() ?: return
val template = CommunityPostApprovalEvent.build(postHint, communityHint)
val signedEvent = signer.sign(template)
val relays = outboxRelays.flow.value + commEvent.relayUrls() + community.relays + (post.author?.inboxRelays() ?: emptyList())
cache.justConsumeMyOwnEvent(signedEvent)
client.send(signedEvent, relays)
}
fun sendAutomatic(events: List<Event>) = events.forEach { sendAutomatic(it) }
fun sendAutomatic(event: Event?) {
@@ -1453,6 +1453,8 @@ object LocalCache : ILocalCache {
// Counts the replies
repliesTo.forEach { it.addBoost(note) }
eventsApproved.forEach { it.addBoost(note) }
event.containedPost()?.let {
checkDeletionAndConsume(it, relay, false)
}
@@ -50,6 +50,7 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
@@ -64,6 +65,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.anyAsync
import com.vitorpamplona.quartz.utils.containsAny
@@ -201,6 +203,16 @@ open class Note(
}
fun relayHintUrl(): NormalizedRelayUrl? {
val noteEvent = event
val communityPostRelays =
when (noteEvent) {
is CommunityDefinitionEvent -> noteEvent.relayUrls().ifEmpty { null }?.toSet()
is IsInPublicChatChannel -> LocalCache.getAnyChannel(this)?.relays()
else -> null
}
if (!communityPostRelays.isNullOrEmpty()) return communityPostRelays.firstOrNull()
val currentOutbox = author?.outboxRelays()?.toSet()
return if (relays.isNotEmpty()) {
@@ -36,12 +36,14 @@ class ThreadAssembler {
): Note? {
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
if (note.event is RepostEvent || note.event is GenericRepostEvent) return note
val noteEvent = note.event
if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) return note
testedNotes.add(note)
val markedAsRoot =
note.event
noteEvent
?.tags
?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }
?.getOrNull(1)
@@ -52,7 +54,10 @@ class ThreadAssembler {
}
}
val hasNoReplyTo = note.replyTo?.reversed()?.firstOrNull { it.replyTo?.isEmpty() == true }
val hasNoReplyTo =
note.replyTo?.lastOrNull {
it.replyTo?.isEmpty() == true
}
if (hasNoReplyTo != null) return hasNoReplyTo
// recursive
@@ -29,6 +29,11 @@ import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.isForCommunity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
@@ -356,3 +361,49 @@ fun observeNoteEdits(
.stateFlow
.collectAsStateWithLifecycle()
}
@Composable
fun observeCommunityApprovalNeedStatus(
note: Note,
community: Note,
accountViewModel: AccountViewModel,
): State<Boolean?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(note, community) {
combine(
community.flow().metadata.stateFlow,
note.flow().boosts.stateFlow,
) { communityMetadata, boosts ->
(communityMetadata.note.event as? CommunityDefinitionEvent)?.let { communityDefEvent ->
val moderators = communityDefEvent.moderatorKeys().toSet()
if (note.author?.pubkeyHex in moderators) {
false
} else {
val isModerator = accountViewModel.account.userProfile().pubkeyHex in moderators
if (isModerator) {
val wasAlreadyApproved =
note.boosts.any {
val approvalEvent = it.event
(approvalEvent is CommunityPostApprovalEvent || approvalEvent is RepostEvent || approvalEvent is GenericRepostEvent) &&
approvalEvent.pubKey in moderators &&
approvalEvent.isForCommunity(community.idHex)
}
!wasAlreadyApproved
} else {
false
}
}
}
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle(false)
}
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
@@ -45,6 +46,7 @@ val RepliesAndReactionsToAddressesKinds1 =
ReportEvent.KIND,
LnZapEvent.KIND,
PollNoteEvent.KIND,
CommentEvent.KIND,
)
val PostsAndChatMessagesToAddresses =
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
@@ -51,7 +52,7 @@ val RepliesAndReactionsKinds =
PollNoteEvent.KIND,
OtsEvent.KIND,
TextNoteModificationEvent.KIND,
GitReplyEvent.KIND,
CommentEvent.KIND,
)
val RepliesAndReactionsKinds2 =
@@ -60,6 +61,7 @@ val RepliesAndReactionsKinds2 =
NIP90ContentDiscoveryResponseEvent.KIND,
NIP90StatusEvent.KIND,
TorrentCommentEvent.KIND,
GitReplyEvent.KIND,
)
fun filterRepliesAndReactionsToNotes(
@@ -108,7 +110,7 @@ fun filterRepliesAndReactionsToNotes(
relay = relay,
filter =
Filter(
kinds = listOf(TextNoteEvent.KIND),
kinds = listOf(TextNoteEvent.KIND, CommentEvent.KIND),
tags = mapOf("q" to sortedList),
since = since,
limit = 1000,
@@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.ui.theme.PaddingHorizontal12Modifier
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
@@ -113,7 +114,7 @@ fun SensitivityWarning(
@Composable
fun ContentWarningNote(onDismiss: () -> Unit) {
Column {
Row(modifier = Modifier.padding(horizontal = 12.dp)) {
Row(modifier = PaddingHorizontal12Modifier) {
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
Box(
@@ -29,7 +29,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -49,8 +48,6 @@ fun FeedLoaded(
) {
val items by loaded.feed.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
LazyColumn(
contentPadding = FeedPadding,
state = listState,
@@ -64,9 +64,10 @@ fun DisappearingScaffold(
bottomBar: (@Composable () -> Unit)? = null,
floatingButton: (@Composable () -> Unit)? = null,
accountViewModel: AccountViewModel,
isActive: () -> Boolean = { true },
mainContent: @Composable (padding: PaddingValues) -> Unit,
) {
val shouldShow = remember { mutableStateOf(true) }
val shouldShow = remember { mutableStateOf(isActive()) }
val modifier =
if (accountViewModel.settings.automaticallyHideNavigationBars == BooleanType.ALWAYS) {
@@ -80,6 +81,13 @@ fun DisappearingScaffold(
available: Offset,
source: NestedScrollSource,
): Offset {
if (!isActive()) {
if (!shouldShow.value) {
shouldShow.value = true
}
return Offset.Zero
}
val newOffset = bottomBarOffsetHeightPx.floatValue + available.y
if (accountViewModel.settings.automaticallyHideNavigationBars == BooleanType.ALWAYS) {
@@ -28,9 +28,11 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -56,6 +58,7 @@ import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelPicture
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeCommunityApprovalNeedStatus
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEdits
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
@@ -139,7 +142,9 @@ import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing10dp
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size30dp
import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
@@ -164,7 +169,6 @@ import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableKind
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
@@ -212,7 +216,9 @@ import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
@@ -552,6 +558,8 @@ fun InnerNoteWithReactions(
accountViewModel = accountViewModel,
nav = nav,
)
RenderApprovalIfNeeded(baseNote, accountViewModel, nav)
}
}
@@ -577,6 +585,47 @@ fun InnerNoteWithReactions(
}
}
@Composable
private fun RenderApprovalIfNeeded(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (baseNote.isNewThread()) {
val communityAddress =
remember(baseNote) {
baseNote.event?.communityAddress()
}
communityAddress?.let {
LoadAddressableNote(it, accountViewModel) { community ->
if (community != null) {
val showApproveButton by observeCommunityApprovalNeedStatus(baseNote, community, accountViewModel)
if (showApproveButton == true) {
RenderApproveButton(baseNote, community, accountViewModel)
}
}
}
}
}
}
@Composable
fun RenderApproveButton(
post: Note,
community: AddressableNote,
accountViewModel: AccountViewModel,
) {
OutlinedButton(
modifier = Modifier.fillMaxWidth().defaultMinSize(minWidth = Size10dp, minHeight = Size30dp),
contentPadding = PaddingValues(0.dp),
onClick = {
accountViewModel.approveCommunityPost(post, community)
},
) {
Text("Approve")
}
}
@Composable
fun NoteBody(
baseNote: Note,
@@ -1090,14 +1139,7 @@ fun FirstUserInfoRow(
) {
Row(verticalAlignment = CenterVertically, modifier = UserNameRowHeight) {
val isRepost = baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent
val isCommunityPost by
remember(baseNote) {
derivedStateOf {
baseNote.event?.isTaggedAddressableKind(CommunityDefinitionEvent.KIND) == true
}
}
val isDraft = baseNote.isDraft()
val textColor = if (isRepost) MaterialTheme.colorScheme.grayText else Color.Unspecified
if (showAuthorPicture) {
@@ -1108,10 +1150,28 @@ fun FirstUserInfoRow(
NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel)
}
if (isCommunityPost) {
DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav)
if (isDraft) {
ObserveDraftEvent(baseNote, accountViewModel) { draftNote ->
val isCommunityPost by remember(draftNote) {
derivedStateOf { draftNote.event?.isACommunityPost() == true }
}
if (isCommunityPost) {
DisplayFollowingCommunityInPost(draftNote, accountViewModel, nav)
} else {
DisplayFollowingHashtagsInPost(draftNote, accountViewModel, nav)
}
}
} else {
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
val isCommunityPost by remember(baseNote) {
derivedStateOf { baseNote.event?.isACommunityPost() == true }
}
if (isCommunityPost) {
DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav)
} else {
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
}
}
if (isRepost) {
@@ -1120,7 +1180,7 @@ fun FirstUserInfoRow(
CheckAndDisplayEditStatus(editState)
if (baseNote.isDraft()) {
if (isDraft) {
DisplayDraft()
}
@@ -1192,7 +1252,7 @@ fun observeEdits(
}
@Composable
private fun BadgeBox(
fun BadgeBox(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
@@ -1207,7 +1267,7 @@ private fun BadgeBox(
}
@Composable
private fun RenderAuthorImages(
fun RenderAuthorImages(
baseNote: Note,
nav: INav,
accountViewModel: AccountViewModel,
@@ -34,9 +34,8 @@ 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.theme.HalfStartPadding
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag
import com.vitorpamplona.quartz.nip01Core.tags.addressables.getTagOfAddressableKind
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
@Composable
fun DisplayFollowingCommunityInPost(
@@ -55,11 +54,15 @@ private fun DisplayCommunity(
nav: INav,
) {
val communityTag =
remember(note) { note.event?.getTagOfAddressableKind(CommunityDefinitionEvent.KIND) } ?: return
remember(note) { note.event?.communityAddress() } ?: return
val displayTag =
remember(note) {
buildLinkString(getCommunityShortName(communityTag)) { nav.nav(Route.Community(communityTag.kind, communityTag.pubKeyHex, communityTag.dTag)) }
buildLinkString(
getCommunityShortName(communityTag),
) {
nav.nav(Route.Community(communityTag.kind, communityTag.pubKeyHex, communityTag.dTag))
}
}
Text(
@@ -75,12 +78,12 @@ private fun DisplayCommunity(
)
}
private fun getCommunityShortName(communityTag: ATag): String {
fun getCommunityShortName(communityAddress: Address): String {
val name =
if (communityTag.dTag.length > 10) {
communityTag.dTag.take(10) + "..."
if (communityAddress.dTag.length > 10) {
communityAddress.dTag.take(10) + "..."
} else {
communityTag.dTag.take(10)
communityAddress.dTag.take(10)
}
return "/n/$name"
@@ -86,6 +86,7 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.nip73ExternalIds.scope
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
@@ -252,8 +253,7 @@ open class CommentPostViewModel :
}
private fun loadFromDraft(draftEvent: CommentEvent) {
val scope = draftEvent.scope() ?: return
this.externalIdentity = scope
this.externalIdentity = draftEvent.scope()
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
@@ -278,8 +278,14 @@ open class CommentPostViewModel :
zapRaiserAmount.value = zapraiser
}
draftEvent.replyingTo()?.let {
replyingTo = LocalCache.getOrCreateNote(it)
val replyAddress = draftEvent.replyAddress()
if (replyAddress.isNotEmpty()) {
replyingTo = LocalCache.getOrCreateAddressableNote(replyAddress.first())
} else {
draftEvent.replyingTo()?.let {
replyingTo = LocalCache.getOrCreateNote(it)
}
}
wantsToAddGeoHash = draftEvent.hasGeohashes()
@@ -356,6 +362,7 @@ open class CommentPostViewModel :
val contentWarningReason = if (wantsToMarkAsSensitive) "" else null
val replyingTo = replyingTo
val replyingToEvent = replyingTo?.event
val template =
if (replyingTo != null) {
@@ -365,7 +372,22 @@ open class CommentPostViewModel :
msg = tagger.message,
replyingTo = eventHint,
) {
tagger.pTags?.let { pTagList -> notify(pTagList.map { it.toPTag() }) }
val notifyPTags = tagger.pTags?.let { pTagList -> pTagList.map { it.toPTag() } } ?: emptyList()
val extraNotificationAuthors =
if (replyingToEvent is CommunityDefinitionEvent) {
replyingToEvent.moderatorKeys().mapNotNull {
if (it != replyingToEvent.pubKey) {
accountViewModel.checkGetOrCreateUser(it)?.toPTag()
} else {
null
}
}
} else {
emptyList()
}
notify((notifyPTags + extraNotificationAuthors).distinctBy { it.pubKey })
hashtags(findHashtags(tagger.message))
references(findURLs(tagger.message))
@@ -20,16 +20,27 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import android.R.attr.onClick
import android.content.Intent
import androidx.compose.foundation.background
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -37,14 +48,18 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
@@ -52,8 +67,11 @@ import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
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.EmptyNav.nav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
@@ -62,9 +80,10 @@ import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.note.elements.NormalTimeAgo
import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.amethyst.ui.stringRes
@@ -74,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
@@ -81,12 +101,12 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList
import com.vitorpamplona.quartz.nip14Subject.subject
import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.util.Locale
import kotlinx.collections.immutable.toImmutableList
@Composable
fun RenderCommunity(
@@ -110,6 +130,16 @@ fun RenderCommunity(
}
}
@Composable
fun Title(title: String) {
Text(
text = title,
fontSize = 16.sp,
fontWeight = FontWeight.W600,
textAlign = TextAlign.Start,
)
}
@Composable
fun LongCommunityHeader(
baseNote: AddressableNote,
@@ -118,142 +148,169 @@ fun LongCommunityHeader(
nav: INav,
) {
val noteEvent by observeNoteEvent<CommunityDefinitionEvent>(baseNote, accountViewModel)
val callbackUri = baseNote.toNostrUri()
Row(
lineModifier,
) {
val rulesLabel = stringRes(id = R.string.rules)
val summary =
remember(noteEvent) {
val subject = noteEvent?.subject()?.ifEmpty { null }
val body = noteEvent?.description()?.ifBlank { null }
val rules = noteEvent?.rules()?.ifBlank { null }
val defaultBackground = MaterialTheme.colorScheme.background
val background = remember { mutableStateOf(defaultBackground) }
if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) {
if (rules == null) {
"### $subject\n$body"
} else {
"### $subject\n$body\n\n### $rulesLabel\n\n$rules"
}
} else {
if (rules == null) {
body
} else {
"$body\n\n$rulesLabel\n$rules"
}
val description = noteEvent?.description()?.ifBlank { null }
val guidelines = noteEvent?.rules()?.ifBlank { null }
val image = noteEvent?.image()?.imageUrl
val tagList = noteEvent?.tags?.toImmutableListOfLists() ?: EmptyTagList
Column(Modifier.verticalScroll(rememberScrollState())) {
Box(contentAlignment = Alignment.TopEnd) {
image?.let {
MyAsyncImage(
imageUrl = it,
contentDescription =
stringRes(
R.string.preview_card_image_for,
it,
),
contentScale = ContentScale.FillWidth,
mainImageModifier = Modifier,
loadedImageModifier = Modifier.fillMaxWidth(),
accountViewModel = accountViewModel,
onLoadingBackground = { DefaultImageHeaderBackground(baseNote, accountViewModel) },
onError = { DefaultImageHeader(baseNote, accountViewModel) },
)
} ?: run {
DefaultImageHeader(baseNote, accountViewModel)
}
Box(
modifier = Modifier.padding(5.dp),
) {
LongCommunityActionOptions(baseNote, accountViewModel, nav)
}
}
Column(lineModifier) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = noteEvent?.name() ?: baseNote.dTag(),
fontSize = 20.sp,
fontWeight = FontWeight.W600,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(8.dp))
Title(title = stringRes(R.string.about_us))
Spacer(modifier = Modifier.height(16.dp))
Column(Modifier.padding(16.dp, 0.dp, 16.dp, 0.dp)) {
TranslatableRichTextViewer(
content = description ?: stringRes(id = R.string.community_no_descriptor),
id = baseNote.idHex + "description",
accountViewModel = accountViewModel,
) {
RichTextViewer(
it,
modifier = Modifier.fillMaxWidth(),
canPreview = false,
quotesLeft = 1,
tags = tagList,
backgroundColor = background,
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
Column(
Modifier.weight(1f),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
val defaultBackground = MaterialTheme.colorScheme.background
val background = remember { mutableStateOf(defaultBackground) }
guidelines?.let {
Spacer(modifier = Modifier.height(24.dp))
Title(title = stringRes(R.string.guidelines))
Spacer(modifier = Modifier.height(16.dp))
TranslatableRichTextViewer(
content = summary ?: stringRes(id = R.string.community_no_descriptor),
canPreview = false,
quotesLeft = 1,
tags = EmptyTagList,
backgroundColor = background,
id = baseNote.idHex,
accountViewModel = accountViewModel,
nav = nav,
)
Column(Modifier.padding(16.dp, 0.dp, 16.dp, 0.dp)) {
TranslatableRichTextViewer(
content = guidelines,
id = baseNote.idHex + "guidelines",
accountViewModel = accountViewModel,
) {
RichTextViewer(
it,
modifier = Modifier.fillMaxWidth(),
canPreview = false,
quotesLeft = 1,
tags = tagList,
backgroundColor = background,
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
noteEvent?.let {
if (it.hasHashtags()) {
DisplayUncitedHashtags(
event = it,
content = summary ?: "",
content = description ?: "",
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
Column {
Row {
Spacer(modifier = Modifier.height(24.dp))
Title(title = stringRes(R.string.owner))
Spacer(modifier = Modifier.height(16.dp))
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
NoteAuthorPicture(baseNote, Size25dp, accountViewModel = accountViewModel, nav = nav)
Spacer(DoubleHorzSpacer)
LongCommunityActionOptions(baseNote, accountViewModel, nav)
NoteUsernameDisplay(baseNote, Modifier.weight(1f), accountViewModel = accountViewModel)
}
}
}
Row(
lineModifier,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(id = R.string.owner),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(75.dp),
)
Spacer(DoubleHorzSpacer)
NoteAuthorPicture(baseNote, Size25dp, accountViewModel = accountViewModel, nav = nav)
Spacer(DoubleHorzSpacer)
NoteUsernameDisplay(baseNote, Modifier.weight(1f), accountViewModel = accountViewModel)
}
var participantUsers by
remember(baseNote) {
mutableStateOf<ImmutableList<Pair<ModeratorTag, User>>>(
persistentListOf(),
)
}
var participantUsers by
remember(baseNote) {
mutableStateOf<ImmutableList<Pair<ModeratorTag, User>>>(
persistentListOf(),
)
}
LaunchedEffect(key1 = noteEvent) {
val participants = noteEvent?.moderators()
LaunchedEffect(key1 = noteEvent) {
val participants = noteEvent?.moderators()
if (participants != null) {
accountViewModel.loadParticipants(participants) { newParticipantUsers ->
if (!equalImmutableLists(newParticipantUsers, participantUsers)) {
participantUsers = newParticipantUsers
if (participants != null) {
accountViewModel.loadParticipants(participants) { newParticipantUsers ->
val noOwner = newParticipantUsers.filter { it.second != baseNote.author }.toImmutableList()
if (!equalImmutableLists(noOwner, participantUsers)) {
participantUsers = noOwner
}
}
}
}
}
}
participantUsers.forEach {
Row(
lineModifier.clickable { nav.nav(routeFor(it.second)) },
verticalAlignment = Alignment.CenterVertically,
) {
it.first.role?.let { it1 ->
Text(
text =
it1.replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(75.dp),
)
if (participantUsers.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
Title(title = stringRes(R.string.moderators))
Spacer(modifier = Modifier.height(16.dp))
participantUsers.forEach {
Column(Modifier.padding(vertical = 5.dp).clickable { nav.nav(routeFor(it.second)) }) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ClickableUserPicture(it.second, Size25dp, accountViewModel)
Spacer(DoubleHorzSpacer)
UsernameDisplay(it.second, Modifier.weight(1f), accountViewModel = accountViewModel)
}
}
}
}
Spacer(DoubleHorzSpacer)
ClickableUserPicture(it.second, Size25dp, accountViewModel)
Spacer(DoubleHorzSpacer)
UsernameDisplay(it.second, Modifier.weight(1f), accountViewModel = accountViewModel)
}
}
Row(
lineModifier,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(id = R.string.created_at),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(75.dp),
)
Spacer(DoubleHorzSpacer)
NormalTimeAgo(baseNote = baseNote, Modifier.weight(1f))
MoreOptionsButton(baseNote, null, accountViewModel, nav)
Spacer(modifier = Modifier.height(15.dp))
}
}
}
@@ -308,7 +365,47 @@ fun ShortCommunityHeader(
}
@Composable
private fun ShortCommunityActionOptions(
fun ShortCommunityHeaderNoActions(
baseNote: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent by observeNoteEvent<CommunityDefinitionEvent>(baseNote, accountViewModel)
Row(verticalAlignment = Alignment.CenterVertically) {
noteEvent?.image()?.let {
RobohashFallbackAsyncImage(
robot = baseNote.idHex,
model = it.imageUrl,
contentDescription = stringRes(R.string.profile_image),
contentScale = ContentScale.Crop,
modifier = HeaderPictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
)
}
Column(
modifier =
Modifier
.padding(start = 10.dp)
.height(Size35dp)
.weight(1f),
verticalArrangement = Arrangement.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = noteEvent?.name() ?: baseNote.dTag(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@Composable
fun ShortCommunityActionOptions(
note: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
@@ -346,9 +443,12 @@ private fun LongCommunityActionOptions(
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchAddressableNoteFollows(note, accountViewModel) { isFollowing ->
if (isFollowing) {
LeaveCommunityButton(accountViewModel, note, nav)
Row {
ShareCommunityButton(accountViewModel, note, nav)
WatchAddressableNoteFollows(note, accountViewModel) { isFollowing ->
if (isFollowing) {
LeaveCommunityButton(accountViewModel, note, nav)
}
}
}
}
@@ -388,18 +488,48 @@ fun LeaveCommunityButton(
note: AddressableNote,
nav: INav,
) {
val scope = rememberCoroutineScope()
Button(
FilledTonalButton(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = { accountViewModel.unfollow(note) },
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
contentPadding = ButtonPadding,
) {
Text(text = stringRes(R.string.leave), color = Color.White)
Text(text = stringRes(R.string.leave))
}
}
@Composable
fun ShareCommunityButton(
accountViewModel: AccountViewModel,
note: AddressableNote,
nav: INav,
) {
val actContext = LocalContext.current
FilledTonalIconButton(
onClick = {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForNote(note),
)
putExtra(
Intent.EXTRA_TITLE,
stringRes(actContext, R.string.quick_action_share_browser_link),
)
}
val shareIntent =
Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share))
ContextCompat.startActivity(actContext, shareIntent, null)
},
) {
Icon(
imageVector = Icons.Default.Share,
modifier = Size18Modifier,
contentDescription = stringRes(R.string.quick_action_share),
)
}
}
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.FeedLoaded
import com.vitorpamplona.amethyst.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
@@ -102,8 +103,7 @@ fun RenderFeedState(
nav: INav,
routeForLastRead: String?,
onLoaded: @Composable (FeedState.Loaded) -> Unit = {
com.vitorpamplona.amethyst.ui.feeds
.FeedLoaded(it, listState, routeForLastRead, accountViewModel, nav)
FeedLoaded(it, listState, routeForLastRead, accountViewModel, nav)
},
onEmpty: @Composable () -> Unit = { FeedEmpty { viewModel.invalidateData() } },
onError: @Composable (String) -> Unit = { FeedError(it) { viewModel.invalidateData() } },
@@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
@@ -123,36 +124,40 @@ class FollowListState(
val livePeopleListsFlow = MutableStateFlow(emptyList<FeedDefinition>())
fun hasItemInNoteList(notes: Set<Note>): Boolean =
notes.any { it ->
val noteEvent = it.event
noteEvent?.pubKey == account.userProfile().pubkeyHex &&
(
(
noteEvent is PeopleListEvent ||
noteEvent is FollowListEvent ||
noteEvent is MuteListEvent ||
noteEvent is ContactListEvent
) ||
(
noteEvent is DeletionEvent &&
(
noteEvent.deleteEventIds().any { LocalCache.getNoteIfExists(it)?.event is PeopleListEvent } ||
noteEvent.deleteAddresses().any { it.kind == PeopleListEvent.KIND }
)
)
)
}
fun deleteFromFeed(deletedNotes: Set<Note>) {
checkNotInMainThread()
if (hasItemInNoteList(deletedNotes)) {
livePeopleListsFlow.tryEmit(getPeopleLists())
}
}
fun updateFeedWith(newNotes: Set<Note>) {
checkNotInMainThread()
val hasNewList =
newNotes.any { it ->
val noteEvent = it.event
noteEvent?.pubKey == account.userProfile().pubkeyHex &&
(
(
noteEvent is PeopleListEvent ||
noteEvent is FollowListEvent ||
noteEvent is MuteListEvent ||
noteEvent is ContactListEvent
) ||
(
noteEvent is DeletionEvent &&
(
noteEvent.deleteEventIds().any {
LocalCache.getNoteIfExists(it)?.event is PeopleListEvent
} ||
noteEvent.deleteAddresses().any {
it.kind == PeopleListEvent.KIND
}
)
)
)
}
if (hasNewList) {
if (hasItemInNoteList(newNotes)) {
livePeopleListsFlow.tryEmit(getPeopleLists())
}
}
@@ -163,7 +168,7 @@ class FollowListState(
checkNotInMainThread()
val communities =
communityList.mapNotNull {
communityList.map {
LocalCache.getOrCreateAddressableNote(it.address).let { communityNote ->
TagFeedDefinition(
"Community/${communityNote.idHex}",
@@ -377,6 +382,7 @@ val DEFAULT_FEED_KINDS =
val DEFAULT_COMMUNITY_FEEDS =
listOf(
TextNoteEvent.KIND,
CommentEvent.KIND,
LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
@@ -774,6 +774,11 @@ class AccountViewModel(
}
}
fun approveCommunityPost(
post: Note,
community: AddressableNote,
) = runIOCatching { account.approveCommunityPost(post, community) }
fun follow(community: AddressableNote) = runIOCatching { account.follow(community) }
fun follow(channel: PublicChatChannel) = runIOCatching { account.follow(channel) }
@@ -20,29 +20,56 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities
import android.R.attr.onClick
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextFieldDefaults.contentPadding
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.TitleIconModifier
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.types.LongCommunityHeader
import com.vitorpamplona.amethyst.ui.note.types.ShortCommunityHeader
import com.vitorpamplona.amethyst.ui.note.types.ShortCommunityActionOptions
import com.vitorpamplona.amethyst.ui.note.types.ShortCommunityHeaderNoActions
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.dal.CommunityFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.dal.CommunityModerationFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import com.vitorpamplona.amethyst.ui.theme.isLight
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import kotlinx.coroutines.launch
@Composable
fun CommunityScreen(
@@ -77,61 +104,116 @@ fun PrepareViewModelsCommunityScreen(
),
)
CommunityScreen(note, followsFeedViewModel, accountViewModel, nav)
val modFeedViewModel: CommunityModerationFeedViewModel =
viewModel(
key = note.idHex + "CommunityModerationFeedViewModel",
factory =
CommunityModerationFeedViewModel.Factory(
note,
accountViewModel.account,
),
)
CommunityScreen(note, followsFeedViewModel, modFeedViewModel, accountViewModel, nav)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CommunityScreen(
note: AddressableNote,
feedViewModel: CommunityFeedViewModel,
modFeedViewModel: CommunityModerationFeedViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(feedViewModel)
WatchLifecycleAndUpdateModel(modFeedViewModel)
CommunityFilterAssemblerSubscription(note, accountViewModel.dataSources().community)
val pagerState = rememberForeverPagerState(note.idHex + "CommunityScreenPagerState") { 2 }
val expanded = remember { mutableStateOf(false) }
DisappearingScaffold(
isInvertedLayout = false,
isActive = { !expanded.value },
topBar = {
CommunityTopBar(note.address, accountViewModel, nav)
Column(
Modifier.clickable { expanded.value = !expanded.value },
) {
ShorterTopAppBar(
title = {
ShortCommunityHeaderNoActions(note, accountViewModel, nav)
},
navigationIcon = {
Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = nav::popBack) { ArrowBackIcon() }
}
},
actions = {
Row(
modifier =
Modifier
.height(Size35dp)
.padding(start = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ShortCommunityActionOptions(note, accountViewModel, nav)
}
},
)
if (expanded.value) {
val elevation = if (MaterialTheme.colorScheme.isLight) 1.dp else 10.dp
Surface(modifier = Modifier.fillMaxWidth(), tonalElevation = elevation) {
LongCommunityHeader(baseNote = note, accountViewModel = accountViewModel, nav = nav)
}
}
}
TabRow(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
modifier = TabRowHeight,
selectedTabIndex = pagerState.currentPage,
) {
val coroutineScope = rememberCoroutineScope()
Tab(
selected = pagerState.currentPage == 0,
text = { Text(text = stringRes(R.string.feed)) },
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
)
Tab(
selected = pagerState.currentPage == 1,
text = { Text(text = stringRes(R.string.mod_queue)) },
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
)
}
},
floatingButton = {
NewCommunityNoteButton(note.idHex, accountViewModel, nav)
},
accountViewModel = accountViewModel,
) {
Column(
modifier = Modifier.padding(it).consumeWindowInsets(it),
) {
RefresheableFeedView(
feedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
fun CommunityTopBar(
id: Address,
accountViewModel: AccountViewModel,
nav: INav,
) {
LoadAddressableNote(id, accountViewModel) { baseNote ->
if (baseNote != null) {
TopBarExtensibleWithBackButton(
title = { ShortCommunityHeader(baseNote, accountViewModel, nav) },
extendableRow = {
Column(Modifier.verticalScroll(rememberScrollState())) {
LongCommunityHeader(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav)
}
},
popBack = nav::popBack,
)
} else {
Spacer(BottomTopHeight)
HorizontalPager(
contentPadding = it,
state = pagerState,
) { page ->
when (page) {
0 ->
RefresheableFeedView(
feedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav,
)
1 ->
RefresheableFeedView(
modFeedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
@@ -57,8 +57,8 @@ fun NewCommunityNoteButton(
FloatingActionButton(
onClick = {
val route =
Route.NewShortNote(
baseReplyTo = note.idHex,
Route.GenericCommentPost(
replyTo = note.idHex,
)
nav.nav(route)
},
@@ -26,21 +26,28 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNote
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.isForCommunity
import kotlin.collections.toSet
class CommunityFeedFilter(
val note: AddressableNote,
val communityDefNote: AddressableNote,
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + note.idHex
val communityDefEvent = communityDefNote.event as? CommunityDefinitionEvent
val moderators = communityDefEvent?.moderatorKeys()?.toSet() ?: emptySet()
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + communityDefNote.idHex
override fun feed(): List<Note> {
val myPubKey = account.userProfile().pubkeyHex
if (communityDefEvent == null) return emptyList()
val result =
LocalCache.notes.mapFlattenIntoSet { _, it ->
filterMap(it, myPubKey)
filterMap(it)
}
return sort(result)
@@ -49,35 +56,39 @@ class CommunityFeedFilter(
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val myPubKey = account.userProfile().pubkeyHex
if (communityDefEvent == null) return emptySet()
return collection
.mapNotNull {
filterMap(it, myPubKey)
filterMap(it)
}.flatten()
.toSet()
}
private fun filterMap(
note: Note,
myPubKey: HexKey,
): List<Note>? =
if (
private fun filterMap(note: Note): List<Note>? {
val noteEvent = note.event ?: return null
return if (
// Only Approvals
note.event is CommunityPostApprovalEvent &&
// Of the given community
note.event?.isTaggedAddressableNote(this.note.idHex) == true
(noteEvent is TextNoteEvent || noteEvent is CommentEvent || noteEvent is CommunityPostApprovalEvent) &&
noteEvent.pubKey in moderators &&
noteEvent.isForCommunity(this.communityDefNote.idHex)
) {
// if it is my post, bring on
if (note.author?.pubkeyHex == myPubKey && note.isNewThread()) {
listOf(note)
if (noteEvent is CommunityPostApprovalEvent) {
note.replyTo?.filter {
it.isNewThread()
}
} else {
// brings the actual posts, not the approvals
note.replyTo?.filter { it.isNewThread() }
if (note.isNewThread()) {
listOf(note)
} else {
null
}
}
} else {
null
}
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -0,0 +1,126 @@
/**
* 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.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.isForCommunity
class CommunityModerationFeedFilter(
val communityDefNote: AddressableNote,
val account: Account,
) : AdditiveFeedFilter<Note>() {
val approvedFilter = CommunityFeedFilter(communityDefNote, account)
val communityDefEvent = communityDefNote.event as? CommunityDefinitionEvent
val moderators = communityDefEvent?.moderatorKeys()?.toSet() ?: emptySet()
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + communityDefNote.idHex
override fun feed(): List<Note> {
if (communityDefEvent == null) return emptyList()
val result =
LocalCache.notes.mapFlattenIntoSet { _, it ->
filterMap(it)
}
return sort(result)
}
override fun updateListWith(
oldList: List<Note>,
newItems: Set<Note>,
): List<Note> {
val approved = approvedFilter.applyFilter(newItems)
val newOldList =
if (approved.isNotEmpty()) {
oldList - approved
} else {
oldList
}
val newItemsToBeAdded = applyFilter(newItems)
return if (newItemsToBeAdded.isNotEmpty()) {
val newList = newOldList.toSet() + newItemsToBeAdded
sort(newList).take(limit())
} else {
newOldList
}
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
if (communityDefEvent == null) return emptySet()
return collection
.mapNotNull {
filterMap(it)
}.flatten()
.toSet()
}
private fun wasApprovedByCommunity(note: Note): Boolean =
note.boosts.any {
val approvalEvent = it.event
(approvalEvent is CommunityPostApprovalEvent || approvalEvent is RepostEvent || approvalEvent is GenericRepostEvent) &&
approvalEvent.pubKey in moderators &&
approvalEvent.isForCommunity(this.communityDefNote.idHex)
}
private fun filterMap(note: Note): List<Note>? {
val noteEvent = note.event ?: return null
return if (
(noteEvent is TextNoteEvent || noteEvent is CommentEvent || noteEvent is CommunityPostApprovalEvent) &&
noteEvent.isForCommunity(this.communityDefNote.idHex) &&
noteEvent.pubKey !in moderators
) {
if (noteEvent is CommunityPostApprovalEvent) {
note.replyTo?.filter {
it.isNewThread() && !wasApprovedByCommunity(it)
}
} else {
if (note.isNewThread() && !wasApprovedByCommunity(note)) {
listOf(note)
} else {
null
}
}
} else {
null
}
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -0,0 +1,40 @@
/**
* 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.dal
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
class CommunityModerationFeedViewModel(
val note: AddressableNote,
val account: Account,
) : FeedViewModel(CommunityModerationFeedFilter(note, account)) {
class Factory(
val note: AddressableNote,
val account: Account,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = CommunityModerationFeedViewModel(note, account) as T
}
}
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.utils.mapOfSet
class CommunityFeedFilterSubAssembler(
client: NostrClient,
@@ -40,14 +41,34 @@ class CommunityFeedFilterSubAssembler(
return keys.flatMap {
val commEvent = it.community.event
if (commEvent is CommunityDefinitionEvent) {
val relays =
commEvent.relayUrls().ifEmpty { null }
?: LocalCache.relayHints.hintsForAddress(commEvent.addressTag()).ifEmpty { null }
?: it.community.relayUrls()
val communityRelays =
(
commEvent.relayUrls().ifEmpty {
LocalCache.relayHints.hintsForAddress(commEvent.addressTag())
} + it.community.relayUrls()
).toSet()
relays.toSet().map {
filterCommunityPosts(it, commEvent, since?.get(it)?.time)
}
val moderatorsOutbox =
mapOfSet {
commEvent.moderatorKeys().forEach { modKey ->
// hits the relay of each moderator for approvals
LocalCache.checkGetOrCreateUser(modKey)?.outboxRelays()?.forEach { relay ->
add(relay, modKey)
}
// static relay hints from this community
communityRelays.forEach { relay ->
add(relay, modKey)
}
}
}
moderatorsOutbox.map { (relay, authors) ->
filterCommunityPostsFromModerators(relay, authors, commEvent, since?.get(relay)?.time)
} +
communityRelays.map { relay ->
filterCommunityPostsFromEverybody(relay, commEvent, since?.get(relay)?.time)
}
} else {
emptyList()
}
@@ -20,11 +20,29 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource
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.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
val CommunityPostKinds =
listOf(
TextNoteEvent.KIND,
CommentEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ClassifiedsEvent.KIND,
LongTextNoteEvent.KIND,
CommunityPostApprovalEvent.KIND,
)
fun filterCommunityPosts(
relay: NormalizedRelayUrl,
@@ -35,10 +53,44 @@ fun filterCommunityPosts(
relay = relay,
filter =
Filter(
authors = community.moderators().map { it.pubKey }.plus(community.pubKey),
authors = community.moderatorKeys(),
tags = mapOf("a" to listOf(community.addressTag())),
kinds = CommunityPostApprovalEvent.KIND_LIST,
kinds = CommunityPostKinds,
limit = 500,
since = since,
),
)
fun filterCommunityPostsFromModerators(
relay: NormalizedRelayUrl,
authors: Set<HexKey>,
community: CommunityDefinitionEvent,
since: Long?,
): RelayBasedFilter =
RelayBasedFilter(
relay = relay,
filter =
Filter(
authors = authors.sorted(),
tags = mapOf("a" to listOf(community.addressTag())),
kinds = CommunityPostKinds,
limit = 100,
since = since,
),
)
fun filterCommunityPostsFromEverybody(
relay: NormalizedRelayUrl,
community: CommunityDefinitionEvent,
since: Long?,
): RelayBasedFilter =
RelayBasedFilter(
relay = relay,
filter =
Filter(
tags = mapOf("a" to listOf(community.addressTag())),
kinds = CommunityPostKinds,
limit = 100,
since = since,
),
)
@@ -33,8 +33,10 @@ import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
@@ -50,6 +52,8 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
@@ -157,7 +161,26 @@ class NotificationFeedFilter(
}
if (event is BaseThreadedEvent) {
if (note.replyTo?.any { it.author?.pubkeyHex == authorHex } == true) return true
if (note.replyTo?.any { it.author?.pubkeyHex == authorHex } == true) {
return true
}
if ((event is TextNoteEvent || event is CommentEvent)) {
val community =
event
.communityAddress()
?.let {
LocalCache.getAddressableNoteIfExists(it)
}?.event as? CommunityDefinitionEvent
if (community != null) {
val moderators = community.moderatorKeys().toSet()
val isModerator = moderators.contains(authorHex)
if (isModerator && event.pubKey !in moderators) {
return true
}
}
}
val isAuthoredPostCited = event.findCitations().any { LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == authorHex }
val isAuthorDirectlyCited = event.citedUsers().contains(authorHex)
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
@@ -70,6 +71,7 @@ class UserProfileNewThreadFeedFilter(
it.author == user &&
(
it.event is TextNoteEvent ||
it.event is CommentEvent ||
it.event is ClassifiedsEvent ||
it.event is RepostEvent ||
it.event is GenericRepostEvent ||
@@ -49,6 +49,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -75,6 +76,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeCommunityApprovalNeedStatus
import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.LoadNote
@@ -96,7 +98,9 @@ import com.vitorpamplona.amethyst.ui.note.LongPressToQuickAction
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.RenderApproveButton
import com.vitorpamplona.amethyst.ui.note.RenderDraft
import com.vitorpamplona.amethyst.ui.note.RenderRepost
import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent
@@ -168,6 +172,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.PaddingHorizontal12Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@@ -189,7 +194,6 @@ import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableKind
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull
@@ -227,7 +231,8 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
@@ -435,6 +440,7 @@ private fun FullBleedNoteCompose(
) {
val noteEvent = baseNote.event ?: return
val isDraft = baseNote.isDraft()
val backgroundColor =
calculateBackgroundColor(
baseNote.createdAt(),
@@ -467,15 +473,28 @@ private fun FullBleedNoteCompose(
Row(verticalAlignment = Alignment.CenterVertically) {
NoteUsernameDisplay(baseNote, Modifier.weight(1f), accountViewModel = accountViewModel)
val isCommunityPost =
remember(baseNote) {
baseNote.event?.isTaggedAddressableKind(CommunityDefinitionEvent.KIND) == true
if (isDraft) {
ObserveDraftEvent(baseNote, accountViewModel) { draftNote ->
val isCommunityPost by remember(draftNote) {
derivedStateOf { draftNote.event?.isACommunityPost() == true }
}
if (isCommunityPost) {
DisplayFollowingCommunityInPost(draftNote, accountViewModel, nav)
} else {
DisplayFollowingHashtagsInPost(draftNote, accountViewModel, nav)
}
}
} else {
val isCommunityPost by remember(baseNote) {
derivedStateOf { baseNote.event?.isACommunityPost() == true }
}
if (isCommunityPost) {
DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav)
} else {
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
if (isCommunityPost) {
DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav)
} else {
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
}
}
CheckAndDisplayEditStatus(editState)
@@ -508,7 +527,7 @@ private fun FullBleedNoteCompose(
DisplayPoW(pow)
}
if (baseNote.isDraft()) {
if (isDraft) {
DisplayDraft()
}
@@ -527,9 +546,7 @@ private fun FullBleedNoteCompose(
}
Row(
modifier =
modifier
.padding(horizontal = 12.dp),
modifier = PaddingHorizontal12Modifier,
) {
Column {
if (noteEvent is ChannelCreateEvent) {
@@ -753,17 +770,43 @@ private fun FullBleedNoteCompose(
val zapSplits = remember(noteEvent) { noteEvent.hasZapSplitSetup() }
if (zapSplits) {
Spacer(modifier = DoubleVertSpacer)
Row(
modifier = Modifier.padding(horizontal = 12.dp),
) {
Row(PaddingHorizontal12Modifier) {
DisplayZapSplits(noteEvent, false, accountViewModel, nav)
}
}
RenderApprovalIfNeeded(baseNote, accountViewModel, nav)
ReactionsRow(baseNote, showReactionDetail = true, addPadding = true, editState = editState, accountViewModel = accountViewModel, nav = nav)
}
}
@Composable
private fun RenderApprovalIfNeeded(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (baseNote.isNewThread()) {
val communityAddress =
remember(baseNote) {
baseNote.event?.communityAddress()
}
communityAddress?.let {
LoadAddressableNote(it, accountViewModel) { community ->
if (community != null) {
val showApproveButton by observeCommunityApprovalNeedStatus(baseNote, community, accountViewModel)
if (showApproveButton == true) {
Row(PaddingHorizontal12Modifier) {
RenderApproveButton(baseNote, community, accountViewModel)
}
}
}
}
}
}
}
@Composable
private fun RenderClassifiedsReaderForThread(
noteEvent: ClassifiedsEvent,
@@ -356,3 +356,5 @@ val BadgePictureModifier = Modifier.size(35.dp).clip(shape = CutCornerShape(20))
val MaxWidthPaddingTop5dp = Modifier.fillMaxWidth().padding(top = 5.dp)
val VoiceHeightModifier = Modifier.fillMaxWidth().height(100.dp)
val PaddingHorizontal12Modifier = Modifier.padding(horizontal = 12.dp)
+6
View File
@@ -175,6 +175,8 @@
<string name="blocked_users">Blocked Users</string>
<string name="new_threads">New Threads</string>
<string name="conversations">Conversations</string>
<string name="feed">Feed</string>
<string name="mod_queue">Mod Queue</string>
<string name="notes">Notes</string>
<string name="replies">Replies</string>
<string name="mutual">Yours</string>
@@ -806,6 +808,10 @@
<string name="created_at">Created at</string>
<string name="rules">Rules</string>
<string name="community_description">About Us</string>
<string name="guidelines">Guidelines</string>
<string name="moderators">Moderators</string>
<string name="login_with_external_signer">Login with Amber</string>
<string name="status_update">Update your status</string>
@@ -81,50 +81,13 @@ fun TranslatableRichTextViewer(
accountViewModel: AccountViewModel,
nav: INav,
) {
var translatedTextState by translateAndWatchLanguageChanges(content, id, accountViewModel)
CrossfadeIfEnabled(targetState = translatedTextState, accountViewModel = accountViewModel) {
RenderText(
translatedTextState = it,
content = content,
canPreview = canPreview,
quotesLeft = quotesLeft,
modifier = modifier,
tags = tags,
backgroundColor = backgroundColor,
id = id,
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Composable
private fun RenderText(
translatedTextState: TranslationConfig,
content: String,
canPreview: Boolean,
quotesLeft: Int,
modifier: Modifier,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
id: String,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
var showOriginal by
remember(translatedTextState) { mutableStateOf(translatedTextState.showOriginal) }
val toBeViewed by
remember(translatedTextState) {
derivedStateOf { if (showOriginal) content else translatedTextState.result ?: content }
}
Column {
TranslatableRichTextViewer(
content = content,
id = id,
accountViewModel = accountViewModel,
) {
ExpandableRichTextViewer(
toBeViewed,
it,
canPreview,
quotesLeft,
modifier,
@@ -135,6 +98,45 @@ private fun RenderText(
accountViewModel,
nav,
)
}
}
@Composable
fun TranslatableRichTextViewer(
content: String,
id: String,
accountViewModel: AccountViewModel,
displayText: @Composable (String) -> Unit,
) {
var translatedTextState by translateAndWatchLanguageChanges(content, id, accountViewModel)
CrossfadeIfEnabled(targetState = translatedTextState, accountViewModel = accountViewModel) {
RenderTextWithTranslateOptions(
translatedTextState = it,
content = content,
accountViewModel = accountViewModel,
displayText = displayText,
)
}
}
@Composable
private fun RenderTextWithTranslateOptions(
translatedTextState: TranslationConfig,
content: String,
accountViewModel: AccountViewModel,
displayText: @Composable (String) -> Unit,
) {
var showOriginal by
remember(translatedTextState) { mutableStateOf(translatedTextState.showOriginal) }
val toBeViewed by
remember(translatedTextState) {
derivedStateOf { if (showOriginal) content else translatedTextState.result ?: content }
}
Column {
displayText(toBeViewed)
if (
translatedTextState.sourceLang != null &&
@@ -325,7 +327,7 @@ fun translateAndWatchLanguageChanges(
id: String,
accountViewModel: AccountViewModel,
): MutableState<TranslationConfig> {
var translatedTextState = remember(id) { mutableStateOf(TranslationsCache.get(content)) }
val translatedTextState = remember(id) { mutableStateOf(TranslationsCache.get(content)) }
TranslateAndWatchLanguageChanges(
content,
@@ -32,8 +32,12 @@ fun Event.isTaggedAddressableNotes(addressIds: Set<String>) = tags.isTaggedAddre
fun Event.isTaggedAddressableKind(kind: Int) = tags.isTaggedAddressableKind(kind)
fun Event.isTaggedAddressableKind(kindStr: String) = tags.isTaggedAddressableKind(kindStr)
fun Event.getTagOfAddressableKind(kind: Int) = tags.getTagOfAddressableKind(kind)
fun Event.getTagOfAddressableKind(kindStr: String) = tags.getTagOfAddressableKind(kindStr)
fun Event.taggedATags() = tags.taggedATags()
fun Event.firstTaggedATag() = tags.firstTaggedATag()
@@ -35,8 +35,12 @@ fun TagArray.isTaggedAddressableNotes(addressIds: Set<String>) = this.any(ATag::
fun TagArray.isTaggedAddressableKind(kind: Int) = this.any(ATag::isTaggedWithKind, kind.toString())
fun TagArray.isTaggedAddressableKind(kindStr: String) = this.any(ATag::isTaggedWithKind, kindStr)
fun TagArray.getTagOfAddressableKind(kind: Int) = this.fastFirstNotNullOfOrNull(ATag::parseIfOfKind, kind.toString())
fun TagArray.getTagOfAddressableKind(kindStr: String) = this.fastFirstNotNullOfOrNull(ATag::parseIfOfKind, kindStr.toString())
fun TagArray.taggedATags() = this.mapNotNull(ATag::parse)
fun TagArray.firstTaggedATag() = this.fastFirstNotNullOfOrNull(ATag::parse)
@@ -72,16 +72,18 @@ open class BaseThreadedEvent(
return newStyleReply ?: newStyleRoot ?: oldStylePositional
}
fun tagsWithoutCitations(): List<String> {
open fun tagsWithoutCitations(): List<String> {
val certainRepliesTo = markedReplyTos()
val uncertainRepliesTo = unmarkedReplyTos()
val tagAddresses =
taggedATags()
.filter {
it.kind != CommunityDefinitionEvent.KIND && (kind != WikiNoteEvent.KIND || it.kind != WikiNoteEvent.KIND)
.filter { aTag ->
aTag.kind != CommunityDefinitionEvent.KIND && (kind != WikiNoteEvent.KIND || aTag.kind != WikiNoteEvent.KIND)
// removes forks from itself.
}.map { it.toTag() }
}.map {
it.toTag()
}
if (certainRepliesTo.isEmpty() && uncertainRepliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList()
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.any
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
@@ -33,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag
@@ -55,10 +57,12 @@ import com.vitorpamplona.quartz.nip22Comments.tags.RootIdentifierTag
import com.vitorpamplona.quartz.nip22Comments.tags.RootKindTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull
import kotlin.collections.mapNotNull
@Immutable
class CommentEvent(
@@ -126,6 +130,28 @@ class CommentEvent(
return aHints + qHints + nip19Hints
}
fun rootEventIds() = tags.mapNotNull(RootEventTag::parseKey)
fun replyEventIds() = tags.mapNotNull(ReplyEventTag::parseKey)
fun rootAddressIds() = tags.mapNotNull(RootAddressTag::parseAddressId)
fun hasRootAddress(addressId: String) = tags.any(RootAddressTag::isTagged, addressId)
fun hasReplyAddress(addressId: String) = tags.any(ReplyAddressTag::isTagged, addressId)
fun replyAddressIds() = tags.mapNotNull(ReplyAddressTag::parseAddressId)
fun replyAddress() = tags.mapNotNull(ReplyAddressTag::parseAddress)
fun rootScopes() = tags.filter { RootIdentifierTag.match(it) || RootAddressTag.match(it) || RootEventTag.match(it) }
fun rootKinds() = tags.filter(RootKindTag::match)
fun directReplies() = tags.filter { ReplyIdentifierTag.match(it) || ReplyAddressTag.match(it) || ReplyEventTag.match(it) }
fun directKinds() = tags.filter(ReplyKindTag::match)
fun rootAuthor() = tags.firstNotNullOfOrNull(RootAuthorTag::parse)
fun replyAuthor() = tags.firstNotNullOfOrNull(ReplyAuthorTag::parse)
@@ -142,14 +168,6 @@ class CommentEvent(
fun replyAuthorHints() = tags.mapNotNull(ReplyAuthorTag::parseAsHint)
fun rootScopes() = tags.filter { RootIdentifierTag.match(it) || RootAddressTag.match(it) || RootEventTag.match(it) }
fun rootKinds() = tags.filter(RootKindTag::match)
fun directReplies() = tags.filter { ReplyIdentifierTag.match(it) || ReplyAddressTag.match(it) || ReplyEventTag.match(it) }
fun directKinds() = tags.filter(ReplyKindTag::match)
/** root and reply scope search */
fun isTaggedScope(scopeId: String) = tags.any { RootIdentifierTag.isTagged(it, scopeId) || ReplyIdentifierTag.isTagged(it, scopeId) }
@@ -164,9 +182,9 @@ class CommentEvent(
fun isScoped(scopeTest: (String) -> Boolean) = tags.any { RootIdentifierTag.isTagged(it, scopeTest) || ReplyIdentifierTag.isTagged(it, scopeTest) }
fun hasRootScopeKind(kind: String) = tags.any { RootKindTag.isKind(it, kind) }
fun hasRootScopeKind(kind: String) = tags.any(RootKindTag::isKind, kind)
fun hasReplyScopeKind(kind: String) = tags.any { ReplyKindTag.isKind(it, kind) }
fun hasReplyScopeKind(kind: String) = tags.any(ReplyKindTag::isKind, kind)
fun hasScopeKind(kind: String) = tags.any { RootKindTag.isKind(it, kind) || ReplyKindTag.isKind(it, kind) }
@@ -184,12 +202,31 @@ class CommentEvent(
tags.lastNotNullOfOrNull(ReplyEventTag::parseKey)
?: tags.lastNotNullOfOrNull(RootEventTag::parseKey)
fun rootAddress() = tags.mapNotNull(RootAddressTag::parseAddress)
fun rootAddressId() = tags.mapNotNull(RootAddressTag::parseAddressId)
fun replyingToAddressId(): String? =
tags.lastNotNullOfOrNull(RootAddressTag::parseAddressId)
?: tags.lastNotNullOfOrNull(ReplyAddressTag::parseAddressId)
tags.lastNotNullOfOrNull(ReplyAddressTag::parseAddressId)
?: tags.lastNotNullOfOrNull(RootAddressTag::parseAddressId)
override fun replyingToAddressOrEvent(): HexKey? = replyingToAddressId() ?: replyingTo()
override fun tagsWithoutCitations(): List<String> {
val rootAddress = rootAddressIds()
val replyAddress = replyAddressIds()
if (
rootAddress.any { Address.isOfKind(it, CommunityDefinitionEvent.KIND_STR) } &&
replyAddress.any { Address.isOfKind(it, CommunityDefinitionEvent.KIND_STR) }
) {
// this is the root of the community post
return emptyList()
}
return rootAddress + replyAddress + rootEventIds() + replyEventIds()
}
companion object {
const val KIND = 1111
const val ALT = "Reply to "
@@ -44,6 +44,12 @@ class ReplyAddressTag(
@JvmStatic
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
@JvmStatic
fun isTagged(
tag: Array<String>,
addressId: String,
) = tag.has(1) && tag[0] == RootAddressTag.Companion.TAG_NAME && tag[1] == addressId
@JvmStatic
fun parse(tag: Array<String>): ReplyAddressTag? {
ensure(tag.has(1)) { return null }
@@ -55,6 +61,15 @@ class ReplyAddressTag(
return ReplyAddressTag(tag[1], relayHint)
}
@JvmStatic
fun parseAddress(tag: Array<String>): Address? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return Address.parse(tag[1])
}
@JvmStatic
fun parseAddressId(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
@@ -67,6 +67,15 @@ class RootAddressTag(
return RootAddressTag(tag[1], relayHint)
}
@JvmStatic
fun parseAddress(tag: Array<String>): Address? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return Address.parse(tag[1])
}
@JvmStatic
fun parseValidAddress(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
@@ -0,0 +1,58 @@
/**
* 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.quartz.nip72ModCommunities
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.any
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.tags.CommunityTag
fun Event.isACommunityPost(): Boolean =
if (this is CommentEvent) {
this.isCommunityScoped()
} else {
this.tags.any(CommunityTag::isTaggedWithKind, CommunityDefinitionEvent.KIND_STR)
}
fun Event.isForCommunity(communityAddressId: String): Boolean =
if (this is CommentEvent) {
this.hasRootAddress(communityAddressId)
} else {
tags.any(CommunityTag::isTagged, communityAddressId)
}
fun Event.communityAddress(): Address? =
if (this is CommentEvent) {
this.rootAddress().firstNotNullOfOrNull {
if (it.kind == CommunityDefinitionEvent.KIND) it else null
}
} else {
this.tags.fastFirstNotNullOfOrNull(CommunityTag::parseAddress)
}
fun CommentEvent.isCommunityScoped() = hasRootScopeKind(CommunityDefinitionEvent.KIND_STR)
fun CommentEvent.communityScope() = this.tags.fastFirstNotNullOfOrNull(CommunityTag::parseAddress)
fun CommentEvent.communityScopes() = this.tags.mapNotNull(CommunityTag::parseAddress)
@@ -25,22 +25,29 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.any
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag
import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedATags
import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag.Companion.isTagged
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip01Core.tags.kinds.kind
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip72ModCommunities.approval.tags.ApprovedAddressTag
import com.vitorpamplona.quartz.nip72ModCommunities.approval.tags.ApprovedAddressTag.Companion.parseAddress
import com.vitorpamplona.quartz.nip72ModCommunities.approval.tags.ApprovedAddressTag.Companion.parseAddressId
import com.vitorpamplona.quartz.nip72ModCommunities.approval.tags.ApprovedAddressTag.Companion.parseAsHint
import com.vitorpamplona.quartz.nip72ModCommunities.approval.tags.ApprovedEventTag
import com.vitorpamplona.quartz.nip72ModCommunities.approval.tags.ApprovedEventTag.Companion.parse
import com.vitorpamplona.quartz.nip72ModCommunities.approval.tags.ApprovedEventTag.Companion.parseAsHint
import com.vitorpamplona.quartz.nip72ModCommunities.approval.tags.ApprovedEventTag.Companion.parseId
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.tags.CommunityTag
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.collections.mapNotNull
@Immutable
class CommunityPostApprovalEvent(
@@ -54,13 +61,17 @@ class CommunityPostApprovalEvent(
EventHintProvider,
AddressHintProvider,
PubKeyHintProvider {
override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint)
override fun eventHints() = tags.mapNotNull(ApprovedEventTag::parseAsHint)
override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + tags.mapNotNull(QTag::parseEventId)
override fun linkedEventIds() = tags.mapNotNull(ApprovedEventTag::parseId)
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(QTag::parseAddressAsHint)
override fun addressHints() =
tags.mapNotNull(ApprovedAddressTag::parseAsHint) +
tags.mapNotNull(CommunityTag::parseAsHint)
override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + tags.mapNotNull(QTag::parseAddressId)
override fun linkedAddressIds() =
tags.mapNotNull(ApprovedAddressTag::parseAddressId) +
tags.mapNotNull(CommunityTag::parseAddressId)
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
@@ -77,15 +88,17 @@ class CommunityPostApprovalEvent(
null
}
fun communities() = taggedATags().filter { it.kind == CommunityDefinitionEvent.KIND }
fun isForCommunity(communityAddressId: String): Boolean = tags.any(CommunityTag::isTagged, communityAddressId)
fun communityAddresses() = taggedAddresses().filter { it.kind == CommunityDefinitionEvent.KIND }
fun isForCommunity(community: Address): Boolean = isForCommunity(community.toValue())
fun approvedEvents() = taggedEvents()
fun communities(): List<CommunityTag> = tags.mapNotNull(CommunityTag::parse)
fun approvedATags() = taggedATags().filter { it.kind != CommunityDefinitionEvent.KIND }
fun communityAddresses(): List<Address> = tags.mapNotNull(CommunityTag::parseAddress)
fun approvedAddresses() = taggedAddresses().filter { it.kind != CommunityDefinitionEvent.KIND }
fun approvedEvents(): List<HexKey> = tags.mapNotNull(ApprovedEventTag::parseId)
fun approvedAddresses(): List<Address> = tags.mapNotNull(ApprovedAddressTag::parseAddress)
companion object {
const val KIND = 4550
@@ -0,0 +1,142 @@
/**
* 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.quartz.nip72ModCommunities.approval.tags
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.ensure
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
class ApprovedAddressTag(
val address: Address,
val relayHint: NormalizedRelayUrl? = null,
) {
fun countMemory(): Long = 2 * pointerSizeInBytes + address.countMemory() + (relayHint?.url?.bytesUsedInMemory() ?: 0)
fun toTag() = Address.assemble(address.kind, address.pubKeyHex, address.dTag)
fun toTagArray() = assemble(address, relayHint)
fun toTagIdOnly() = assemble(address, null)
companion object {
const val TAG_NAME = "a"
@JvmStatic
fun isTagged(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && !Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)
@JvmStatic
fun isTagged(
tag: Array<String>,
addressId: String,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == addressId
@JvmStatic
fun isTagged(
tag: Array<String>,
address: ApprovedAddressTag,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == address.toTag()
@JvmStatic
fun isIn(
tag: Array<String>,
addressIds: Set<String>,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in addressIds
@JvmStatic
fun parse(tag: Array<String>): ApprovedAddressTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null }
val address = Address.parse(tag[1]) ?: return null
val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }
return ApprovedAddressTag(address, relayHint)
}
@JvmStatic
fun parseValidAddress(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null }
return Address.parse(tag[1])?.toValue()
}
@JvmStatic
fun parseAddress(tag: Array<String>): Address? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
val address = Address.parse(tag[1]) ?: return null
ensure(address.kind != CommunityDefinitionEvent.KIND) { return null }
return address
}
@JvmStatic
fun parseAddressId(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null }
return tag[1]
}
@JvmStatic
fun parseAsHint(tag: Array<String>): AddressHint? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null }
ensure(tag[1].contains(':')) { return null }
ensure(tag[2].isNotEmpty()) { return null }
val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2])
ensure(relayHint != null) { return null }
return AddressHint(tag[1], relayHint)
}
@JvmStatic
fun assemble(
aTagId: HexKey,
relay: NormalizedRelayUrl?,
) = arrayOfNotNull(TAG_NAME, aTagId, relay?.url)
@JvmStatic
fun assemble(
address: Address,
relay: NormalizedRelayUrl?,
) = arrayOfNotNull(TAG_NAME, address.toValue(), relay?.url)
@JvmStatic
fun assemble(
kind: Int,
pubKey: String,
dTag: String,
relay: NormalizedRelayUrl?,
) = assemble(Address.assemble(kind, pubKey, dTag), relay)
}
}
@@ -0,0 +1,109 @@
/**
* 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.quartz.nip72ModCommunities.approval.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
@Immutable
class ApprovedEventTag(
val ref: EventReference,
) {
constructor(eventId: String, relayHint: NormalizedRelayUrl?, pubkey: String?) : this(EventReference(eventId, pubkey, relayHint))
fun toTagArray() = assemble(ref)
companion object {
const val TAG_NAME = "e"
@JvmStatic
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
@JvmStatic
fun isTagged(
tag: Array<String>,
eventId: String,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == eventId
@JvmStatic
fun isIn(
tag: Array<String>,
eventIds: Set<String>,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in eventIds
@JvmStatic
fun parse(tag: Array<String>): ApprovedEventTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return ApprovedEventTag(tag[1], tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }, tag.getOrNull(3))
}
@JvmStatic
fun parseId(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return tag[1]
}
@JvmStatic
fun parseValidId(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
ensure(Hex.isHex(tag[1])) { return null }
return tag[1]
}
@JvmStatic
fun parseAsHint(tag: Array<String>): EventIdHint? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
ensure(tag[2].isNotEmpty()) { return null }
val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2])
ensure(relayHint != null) { return null }
return EventIdHint(tag[1], relayHint)
}
@JvmStatic
fun assemble(
eventId: HexKey,
relay: NormalizedRelayUrl?,
pubkey: String?,
) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, pubkey)
@JvmStatic
fun assemble(ref: EventReference) = assemble(ref.eventId, ref.relayHint, ref.author)
}
}
@@ -80,7 +80,7 @@ class CommunityDefinitionEvent(
fun moderators() = tags.mapNotNull(ModeratorTag::parse)
fun moderatorKeys() = tags.mapNotNull(ModeratorTag::parseKey)
fun moderatorKeys() = tags.mapNotNull(ModeratorTag::parseKey).plus(pubKey)
fun relays() = tags.mapNotNull(RelayTag::parse)
@@ -88,6 +88,7 @@ class CommunityDefinitionEvent(
companion object {
const val KIND = 34550
const val KIND_STR = "34550"
const val ALT_DESCRIPTION = "Community definition"
fun build(
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.ensure
@@ -47,7 +48,7 @@ class CommunityTag(
const val TAG_NAME = "a"
@JvmStatic
fun isTagged(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun isTagged(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)
@JvmStatic
fun isTagged(
@@ -61,12 +62,6 @@ class CommunityTag(
address: CommunityTag,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == address.toTag()
@JvmStatic
fun isIn(
tag: Array<String>,
addressIds: Set<String>,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in addressIds
@JvmStatic
fun isTaggedWithKind(
tag: Array<String>,
@@ -74,26 +69,27 @@ class CommunityTag(
) = tag.has(1) && tag[0] == TAG_NAME && Address.isOfKind(tag[1], kind)
@JvmStatic
fun parse(
aTagId: String,
relay: String?,
) = Address.parse(aTagId)?.let {
CommunityTag(it, relay?.let { RelayUrlNormalizer.normalizeOrNull(it) })
}
fun isIn(
tag: Array<String>,
addressIds: Set<String>,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in addressIds
@JvmStatic
fun parse(tag: Array<String>): CommunityTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return parse(tag[1], tag.getOrNull(2))
ensure(Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null }
val address = Address.parse(tag[1]) ?: return null
val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }
return CommunityTag(address, relayHint)
}
@JvmStatic
fun parseValidAddress(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
ensure(Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null }
return Address.parse(tag[1])?.toValue()
}
@@ -102,14 +98,16 @@ class CommunityTag(
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return Address.parse(tag[1])
val address = Address.parse(tag[1]) ?: return null
ensure(address.kind == CommunityDefinitionEvent.KIND) { return null }
return address
}
@JvmStatic
fun parseAddressId(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
ensure(Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null }
return tag[1]
}
@@ -117,8 +115,7 @@ class CommunityTag(
fun parseAsHint(tag: Array<String>): AddressHint? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
ensure(tag[1].contains(':')) { return null }
ensure(Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null }
ensure(tag[2].isNotEmpty()) { return null }
val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2])