- 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.VideoMeta
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.nip92IMeta.imetas
@@ -863,6 +865,24 @@ class Account(
suspend fun unfollowGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.unfollow(geohash)) 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(events: List<Event>) = events.forEach { sendAutomatic(it) }
fun sendAutomatic(event: Event?) { fun sendAutomatic(event: Event?) {
@@ -1453,6 +1453,8 @@ object LocalCache : ILocalCache {
// Counts the replies // Counts the replies
repliesTo.forEach { it.addBoost(note) } repliesTo.forEach { it.addBoost(note) }
eventsApproved.forEach { it.addBoost(note) }
event.containedPost()?.let { event.containedPost()?.let {
checkDeletionAndConsume(it, relay, false) 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.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent 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.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.anyAsync import com.vitorpamplona.quartz.utils.anyAsync
import com.vitorpamplona.quartz.utils.containsAny import com.vitorpamplona.quartz.utils.containsAny
@@ -201,6 +203,16 @@ open class Note(
} }
fun relayHintUrl(): NormalizedRelayUrl? { 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() val currentOutbox = author?.outboxRelays()?.toSet()
return if (relays.isNotEmpty()) { return if (relays.isNotEmpty()) {
@@ -36,12 +36,14 @@ class ThreadAssembler {
): Note? { ): Note? {
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return 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) testedNotes.add(note)
val markedAsRoot = val markedAsRoot =
note.event noteEvent
?.tags ?.tags
?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" } ?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }
?.getOrNull(1) ?.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 if (hasNoReplyTo != null) return hasNoReplyTo
// recursive // recursive
@@ -29,6 +29,11 @@ import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event 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.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
@@ -356,3 +361,49 @@ fun observeNoteEdits(
.stateFlow .stateFlow
.collectAsStateWithLifecycle() .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.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent
@@ -45,6 +46,7 @@ val RepliesAndReactionsToAddressesKinds1 =
ReportEvent.KIND, ReportEvent.KIND,
LnZapEvent.KIND, LnZapEvent.KIND,
PollNoteEvent.KIND, PollNoteEvent.KIND,
CommentEvent.KIND,
) )
val PostsAndChatMessagesToAddresses = val PostsAndChatMessagesToAddresses =
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
@@ -51,7 +52,7 @@ val RepliesAndReactionsKinds =
PollNoteEvent.KIND, PollNoteEvent.KIND,
OtsEvent.KIND, OtsEvent.KIND,
TextNoteModificationEvent.KIND, TextNoteModificationEvent.KIND,
GitReplyEvent.KIND, CommentEvent.KIND,
) )
val RepliesAndReactionsKinds2 = val RepliesAndReactionsKinds2 =
@@ -60,6 +61,7 @@ val RepliesAndReactionsKinds2 =
NIP90ContentDiscoveryResponseEvent.KIND, NIP90ContentDiscoveryResponseEvent.KIND,
NIP90StatusEvent.KIND, NIP90StatusEvent.KIND,
TorrentCommentEvent.KIND, TorrentCommentEvent.KIND,
GitReplyEvent.KIND,
) )
fun filterRepliesAndReactionsToNotes( fun filterRepliesAndReactionsToNotes(
@@ -108,7 +110,7 @@ fun filterRepliesAndReactionsToNotes(
relay = relay, relay = relay,
filter = filter =
Filter( Filter(
kinds = listOf(TextNoteEvent.KIND), kinds = listOf(TextNoteEvent.KIND, CommentEvent.KIND),
tags = mapOf("q" to sortedList), tags = mapOf("q" to sortedList),
since = since, since = since,
limit = 1000, 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.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.ButtonPadding 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.nip01Core.core.Event
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
@@ -113,7 +114,7 @@ fun SensitivityWarning(
@Composable @Composable
fun ContentWarningNote(onDismiss: () -> Unit) { fun ContentWarningNote(onDismiss: () -> Unit) {
Column { Column {
Row(modifier = Modifier.padding(horizontal = 12.dp)) { Row(modifier = PaddingHorizontal12Modifier) {
Column(modifier = Modifier.padding(start = 10.dp)) { Column(modifier = Modifier.padding(start = 10.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
Box( Box(
@@ -29,7 +29,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -49,8 +48,6 @@ fun FeedLoaded(
) { ) {
val items by loaded.feed.collectAsStateWithLifecycle() val items by loaded.feed.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
LazyColumn( LazyColumn(
contentPadding = FeedPadding, contentPadding = FeedPadding,
state = listState, state = listState,
@@ -64,9 +64,10 @@ fun DisappearingScaffold(
bottomBar: (@Composable () -> Unit)? = null, bottomBar: (@Composable () -> Unit)? = null,
floatingButton: (@Composable () -> Unit)? = null, floatingButton: (@Composable () -> Unit)? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
isActive: () -> Boolean = { true },
mainContent: @Composable (padding: PaddingValues) -> Unit, mainContent: @Composable (padding: PaddingValues) -> Unit,
) { ) {
val shouldShow = remember { mutableStateOf(true) } val shouldShow = remember { mutableStateOf(isActive()) }
val modifier = val modifier =
if (accountViewModel.settings.automaticallyHideNavigationBars == BooleanType.ALWAYS) { if (accountViewModel.settings.automaticallyHideNavigationBars == BooleanType.ALWAYS) {
@@ -80,6 +81,13 @@ fun DisappearingScaffold(
available: Offset, available: Offset,
source: NestedScrollSource, source: NestedScrollSource,
): Offset { ): Offset {
if (!isActive()) {
if (!shouldShow.value) {
shouldShow.value = true
}
return Offset.Zero
}
val newOffset = bottomBarOffsetHeightPx.floatValue + available.y val newOffset = bottomBarOffsetHeightPx.floatValue + available.y
if (accountViewModel.settings.automaticallyHideNavigationBars == BooleanType.ALWAYS) { 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.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect 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.Note
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelPicture 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.observeNoteEdits
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.GenericLoadable 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.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing10dp import com.vitorpamplona.amethyst.ui.theme.RowColSpacing10dp
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp 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.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size30dp
import com.vitorpamplona.amethyst.ui.theme.Size34dp import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp 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.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent 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.nip01Core.tags.geohash.geoHashOrScope
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent 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.VideoShortEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent 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.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
@@ -552,6 +558,8 @@ fun InnerNoteWithReactions(
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, 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 @Composable
fun NoteBody( fun NoteBody(
baseNote: Note, baseNote: Note,
@@ -1090,14 +1139,7 @@ fun FirstUserInfoRow(
) { ) {
Row(verticalAlignment = CenterVertically, modifier = UserNameRowHeight) { Row(verticalAlignment = CenterVertically, modifier = UserNameRowHeight) {
val isRepost = baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent val isRepost = baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent
val isDraft = baseNote.isDraft()
val isCommunityPost by
remember(baseNote) {
derivedStateOf {
baseNote.event?.isTaggedAddressableKind(CommunityDefinitionEvent.KIND) == true
}
}
val textColor = if (isRepost) MaterialTheme.colorScheme.grayText else Color.Unspecified val textColor = if (isRepost) MaterialTheme.colorScheme.grayText else Color.Unspecified
if (showAuthorPicture) { if (showAuthorPicture) {
@@ -1108,11 +1150,29 @@ fun FirstUserInfoRow(
NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel) NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel)
} }
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) { if (isCommunityPost) {
DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav) DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav)
} else { } else {
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav) DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
} }
}
if (isRepost) { if (isRepost) {
BoostedMark() BoostedMark()
@@ -1120,7 +1180,7 @@ fun FirstUserInfoRow(
CheckAndDisplayEditStatus(editState) CheckAndDisplayEditStatus(editState)
if (baseNote.isDraft()) { if (isDraft) {
DisplayDraft() DisplayDraft()
} }
@@ -1192,7 +1252,7 @@ fun observeEdits(
} }
@Composable @Composable
private fun BadgeBox( fun BadgeBox(
baseNote: Note, baseNote: Note,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
@@ -1207,7 +1267,7 @@ private fun BadgeBox(
} }
@Composable @Composable
private fun RenderAuthorImages( fun RenderAuthorImages(
baseNote: Note, baseNote: Note,
nav: INav, nav: INav,
accountViewModel: AccountViewModel, 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.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip01Core.tags.addressables.getTagOfAddressableKind import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
@Composable @Composable
fun DisplayFollowingCommunityInPost( fun DisplayFollowingCommunityInPost(
@@ -55,11 +54,15 @@ private fun DisplayCommunity(
nav: INav, nav: INav,
) { ) {
val communityTag = val communityTag =
remember(note) { note.event?.getTagOfAddressableKind(CommunityDefinitionEvent.KIND) } ?: return remember(note) { note.event?.communityAddress() } ?: return
val displayTag = val displayTag =
remember(note) { 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( Text(
@@ -75,12 +78,12 @@ private fun DisplayCommunity(
) )
} }
private fun getCommunityShortName(communityTag: ATag): String { fun getCommunityShortName(communityAddress: Address): String {
val name = val name =
if (communityTag.dTag.length > 10) { if (communityAddress.dTag.length > 10) {
communityTag.dTag.take(10) + "..." communityAddress.dTag.take(10) + "..."
} else { } else {
communityTag.dTag.take(10) communityAddress.dTag.take(10)
} }
return "/n/$name" 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.splits.zapSplits
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount 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.ExternalId
import com.vitorpamplona.quartz.nip73ExternalIds.scope import com.vitorpamplona.quartz.nip73ExternalIds.scope
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
@@ -252,8 +253,7 @@ open class CommentPostViewModel :
} }
private fun loadFromDraft(draftEvent: CommentEvent) { private fun loadFromDraft(draftEvent: CommentEvent) {
val scope = draftEvent.scope() ?: return this.externalIdentity = draftEvent.scope()
this.externalIdentity = scope
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
@@ -278,9 +278,15 @@ open class CommentPostViewModel :
zapRaiserAmount.value = zapraiser zapRaiserAmount.value = zapraiser
} }
val replyAddress = draftEvent.replyAddress()
if (replyAddress.isNotEmpty()) {
replyingTo = LocalCache.getOrCreateAddressableNote(replyAddress.first())
} else {
draftEvent.replyingTo()?.let { draftEvent.replyingTo()?.let {
replyingTo = LocalCache.getOrCreateNote(it) replyingTo = LocalCache.getOrCreateNote(it)
} }
}
wantsToAddGeoHash = draftEvent.hasGeohashes() wantsToAddGeoHash = draftEvent.hasGeohashes()
@@ -356,6 +362,7 @@ open class CommentPostViewModel :
val contentWarningReason = if (wantsToMarkAsSensitive) "" else null val contentWarningReason = if (wantsToMarkAsSensitive) "" else null
val replyingTo = replyingTo val replyingTo = replyingTo
val replyingToEvent = replyingTo?.event
val template = val template =
if (replyingTo != null) { if (replyingTo != null) {
@@ -365,7 +372,22 @@ open class CommentPostViewModel :
msg = tagger.message, msg = tagger.message,
replyingTo = eventHint, 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)) hashtags(findHashtags(tagger.message))
references(findURLs(tagger.message)) references(findURLs(tagger.message))
@@ -20,16 +20,27 @@
*/ */
package com.vitorpamplona.amethyst.ui.note.types 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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.Button
import androidx.compose.material3.ButtonDefaults 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -37,14 +48,18 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote 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.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent 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.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer 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.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture 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.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ZapReaction 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.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
import com.vitorpamplona.amethyst.ui.note.elements.NormalTimeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.amethyst.ui.stringRes 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.HeaderPictureModifier
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing import com.vitorpamplona.amethyst.ui.theme.RowColSpacing
import com.vitorpamplona.amethyst.ui.theme.Size10dp 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.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp 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.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList 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.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import java.util.Locale import kotlinx.collections.immutable.toImmutableList
@Composable @Composable
fun RenderCommunity( 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 @Composable
fun LongCommunityHeader( fun LongCommunityHeader(
baseNote: AddressableNote, baseNote: AddressableNote,
@@ -118,82 +148,123 @@ fun LongCommunityHeader(
nav: INav, nav: INav,
) { ) {
val noteEvent by observeNoteEvent<CommunityDefinitionEvent>(baseNote, accountViewModel) 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 }
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"
}
}
}
Column(
Modifier.weight(1f),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
val defaultBackground = MaterialTheme.colorScheme.background val defaultBackground = MaterialTheme.colorScheme.background
val background = remember { mutableStateOf(defaultBackground) } val background = remember { mutableStateOf(defaultBackground) }
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( TranslatableRichTextViewer(
content = summary ?: stringRes(id = R.string.community_no_descriptor), content = description ?: stringRes(id = R.string.community_no_descriptor),
id = baseNote.idHex + "description",
accountViewModel = accountViewModel,
) {
RichTextViewer(
it,
modifier = Modifier.fillMaxWidth(),
canPreview = false, canPreview = false,
quotesLeft = 1, quotesLeft = 1,
tags = EmptyTagList, tags = tagList,
backgroundColor = background, backgroundColor = background,
id = baseNote.idHex, callbackUri = callbackUri,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
} }
}
guidelines?.let {
Spacer(modifier = Modifier.height(24.dp))
Title(title = stringRes(R.string.guidelines))
Spacer(modifier = Modifier.height(16.dp))
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 { noteEvent?.let {
if (it.hasHashtags()) { if (it.hasHashtags()) {
DisplayUncitedHashtags( DisplayUncitedHashtags(
event = it, event = it,
content = summary ?: "", content = description ?: "",
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
} }
} }
}
Column { Spacer(modifier = Modifier.height(24.dp))
Row { Title(title = stringRes(R.string.owner))
Spacer(DoubleHorzSpacer) Spacer(modifier = Modifier.height(16.dp))
LongCommunityActionOptions(baseNote, accountViewModel, nav)
}
}
}
Row( Row(
lineModifier, Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically, 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) NoteAuthorPicture(baseNote, Size25dp, accountViewModel = accountViewModel, nav = nav)
Spacer(DoubleHorzSpacer) Spacer(DoubleHorzSpacer)
NoteUsernameDisplay(baseNote, Modifier.weight(1f), accountViewModel = accountViewModel) NoteUsernameDisplay(baseNote, Modifier.weight(1f), accountViewModel = accountViewModel)
@@ -211,49 +282,35 @@ fun LongCommunityHeader(
if (participants != null) { if (participants != null) {
accountViewModel.loadParticipants(participants) { newParticipantUsers -> accountViewModel.loadParticipants(participants) { newParticipantUsers ->
if (!equalImmutableLists(newParticipantUsers, participantUsers)) { val noOwner = newParticipantUsers.filter { it.second != baseNote.author }.toImmutableList()
participantUsers = newParticipantUsers if (!equalImmutableLists(noOwner, participantUsers)) {
participantUsers = noOwner
} }
} }
} }
} }
if (participantUsers.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
Title(title = stringRes(R.string.moderators))
Spacer(modifier = Modifier.height(16.dp))
participantUsers.forEach { participantUsers.forEach {
Column(Modifier.padding(vertical = 5.dp).clickable { nav.nav(routeFor(it.second)) }) {
Row( Row(
lineModifier.clickable { nav.nav(routeFor(it.second)) }, Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically, 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),
)
}
Spacer(DoubleHorzSpacer)
ClickableUserPicture(it.second, Size25dp, accountViewModel) ClickableUserPicture(it.second, Size25dp, accountViewModel)
Spacer(DoubleHorzSpacer) Spacer(DoubleHorzSpacer)
UsernameDisplay(it.second, Modifier.weight(1f), accountViewModel = accountViewModel) UsernameDisplay(it.second, Modifier.weight(1f), accountViewModel = accountViewModel)
} }
} }
}
}
Row( Spacer(modifier = Modifier.height(15.dp))
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)
} }
} }
@@ -308,7 +365,47 @@ fun ShortCommunityHeader(
} }
@Composable @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, note: AddressableNote,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
@@ -346,11 +443,14 @@ private fun LongCommunityActionOptions(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
Row {
ShareCommunityButton(accountViewModel, note, nav)
WatchAddressableNoteFollows(note, accountViewModel) { isFollowing -> WatchAddressableNoteFollows(note, accountViewModel) { isFollowing ->
if (isFollowing) { if (isFollowing) {
LeaveCommunityButton(accountViewModel, note, nav) LeaveCommunityButton(accountViewModel, note, nav)
} }
} }
}
} }
@Composable @Composable
@@ -388,18 +488,48 @@ fun LeaveCommunityButton(
note: AddressableNote, note: AddressableNote,
nav: INav, nav: INav,
) { ) {
val scope = rememberCoroutineScope() FilledTonalButton(
Button(
modifier = Modifier.padding(horizontal = 3.dp), modifier = Modifier.padding(horizontal = 3.dp),
onClick = { accountViewModel.unfollow(note) }, onClick = { accountViewModel.unfollow(note) },
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
contentPadding = ButtonPadding, 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.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.FeedError 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.FeedState
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
@@ -102,8 +103,7 @@ fun RenderFeedState(
nav: INav, nav: INav,
routeForLastRead: String?, routeForLastRead: String?,
onLoaded: @Composable (FeedState.Loaded) -> Unit = { 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() } }, onEmpty: @Composable () -> Unit = { FeedEmpty { viewModel.invalidateData() } },
onError: @Composable (String) -> Unit = { FeedError(it) { 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.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
@@ -123,11 +124,8 @@ class FollowListState(
val livePeopleListsFlow = MutableStateFlow(emptyList<FeedDefinition>()) val livePeopleListsFlow = MutableStateFlow(emptyList<FeedDefinition>())
fun updateFeedWith(newNotes: Set<Note>) { fun hasItemInNoteList(notes: Set<Note>): Boolean =
checkNotInMainThread() notes.any { it ->
val hasNewList =
newNotes.any { it ->
val noteEvent = it.event val noteEvent = it.event
noteEvent?.pubKey == account.userProfile().pubkeyHex && noteEvent?.pubKey == account.userProfile().pubkeyHex &&
@@ -141,18 +139,25 @@ class FollowListState(
( (
noteEvent is DeletionEvent && noteEvent is DeletionEvent &&
( (
noteEvent.deleteEventIds().any { noteEvent.deleteEventIds().any { LocalCache.getNoteIfExists(it)?.event is PeopleListEvent } ||
LocalCache.getNoteIfExists(it)?.event is PeopleListEvent noteEvent.deleteAddresses().any { it.kind == PeopleListEvent.KIND }
} ||
noteEvent.deleteAddresses().any {
it.kind == PeopleListEvent.KIND
}
) )
) )
) )
} }
if (hasNewList) { fun deleteFromFeed(deletedNotes: Set<Note>) {
checkNotInMainThread()
if (hasItemInNoteList(deletedNotes)) {
livePeopleListsFlow.tryEmit(getPeopleLists())
}
}
fun updateFeedWith(newNotes: Set<Note>) {
checkNotInMainThread()
if (hasItemInNoteList(newNotes)) {
livePeopleListsFlow.tryEmit(getPeopleLists()) livePeopleListsFlow.tryEmit(getPeopleLists())
} }
} }
@@ -163,7 +168,7 @@ class FollowListState(
checkNotInMainThread() checkNotInMainThread()
val communities = val communities =
communityList.mapNotNull { communityList.map {
LocalCache.getOrCreateAddressableNote(it.address).let { communityNote -> LocalCache.getOrCreateAddressableNote(it.address).let { communityNote ->
TagFeedDefinition( TagFeedDefinition(
"Community/${communityNote.idHex}", "Community/${communityNote.idHex}",
@@ -377,6 +382,7 @@ val DEFAULT_FEED_KINDS =
val DEFAULT_COMMUNITY_FEEDS = val DEFAULT_COMMUNITY_FEEDS =
listOf( listOf(
TextNoteEvent.KIND, TextNoteEvent.KIND,
CommentEvent.KIND,
LongTextNoteEvent.KIND, LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND, ClassifiedsEvent.KIND,
HighlightEvent.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(community: AddressableNote) = runIOCatching { account.follow(community) }
fun follow(channel: PublicChatChannel) = runIOCatching { account.follow(channel) } fun follow(channel: PublicChatChannel) = runIOCatching { account.follow(channel) }
@@ -20,29 +20,56 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities 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.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.verticalScroll 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.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.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel 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.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav 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.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.types.LongCommunityHeader 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.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.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.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 com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import kotlinx.coroutines.launch
@Composable @Composable
fun CommunityScreen( 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 @Composable
fun CommunityScreen( fun CommunityScreen(
note: AddressableNote, note: AddressableNote,
feedViewModel: CommunityFeedViewModel, feedViewModel: CommunityFeedViewModel,
modFeedViewModel: CommunityModerationFeedViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
WatchLifecycleAndUpdateModel(feedViewModel) WatchLifecycleAndUpdateModel(feedViewModel)
WatchLifecycleAndUpdateModel(modFeedViewModel)
CommunityFilterAssemblerSubscription(note, accountViewModel.dataSources().community) CommunityFilterAssemblerSubscription(note, accountViewModel.dataSources().community)
val pagerState = rememberForeverPagerState(note.idHex + "CommunityScreenPagerState") { 2 }
val expanded = remember { mutableStateOf(false) }
DisappearingScaffold( DisappearingScaffold(
isInvertedLayout = false, isInvertedLayout = false,
isActive = { !expanded.value },
topBar = { 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 = { floatingButton = {
NewCommunityNoteButton(note.idHex, accountViewModel, nav) NewCommunityNoteButton(note.idHex, accountViewModel, nav)
}, },
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) { ) {
Column( HorizontalPager(
modifier = Modifier.padding(it).consumeWindowInsets(it), contentPadding = it,
) { state = pagerState,
) { page ->
when (page) {
0 ->
RefresheableFeedView( RefresheableFeedView(
feedViewModel, feedViewModel,
null, null,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
} 1 ->
} RefresheableFeedView(
} modFeedViewModel,
null,
@Composable accountViewModel = accountViewModel,
fun CommunityTopBar( nav = nav,
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)
} }
} }
} }
@@ -57,8 +57,8 @@ fun NewCommunityNoteButton(
FloatingActionButton( FloatingActionButton(
onClick = { onClick = {
val route = val route =
Route.NewShortNote( Route.GenericCommentPost(
baseReplyTo = note.idHex, replyTo = note.idHex,
) )
nav.nav(route) nav.nav(route)
}, },
@@ -26,21 +26,28 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNote import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.isForCommunity
import kotlin.collections.toSet
class CommunityFeedFilter( class CommunityFeedFilter(
val note: AddressableNote, val communityDefNote: AddressableNote,
val account: Account, val account: Account,
) : AdditiveFeedFilter<Note>() { ) : 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> { override fun feed(): List<Note> {
val myPubKey = account.userProfile().pubkeyHex if (communityDefEvent == null) return emptyList()
val result = val result =
LocalCache.notes.mapFlattenIntoSet { _, it -> LocalCache.notes.mapFlattenIntoSet { _, it ->
filterMap(it, myPubKey) filterMap(it)
} }
return sort(result) return sort(result)
@@ -49,35 +56,39 @@ class CommunityFeedFilter(
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems) override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> { private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val myPubKey = account.userProfile().pubkeyHex if (communityDefEvent == null) return emptySet()
return collection return collection
.mapNotNull { .mapNotNull {
filterMap(it, myPubKey) filterMap(it)
}.flatten() }.flatten()
.toSet() .toSet()
} }
private fun filterMap( private fun filterMap(note: Note): List<Note>? {
note: Note, val noteEvent = note.event ?: return null
myPubKey: HexKey,
): List<Note>? = return if (
if (
// Only Approvals // Only Approvals
note.event is CommunityPostApprovalEvent && (noteEvent is TextNoteEvent || noteEvent is CommentEvent || noteEvent is CommunityPostApprovalEvent) &&
// Of the given community noteEvent.pubKey in moderators &&
note.event?.isTaggedAddressableNote(this.note.idHex) == true noteEvent.isForCommunity(this.communityDefNote.idHex)
) { ) {
// if it is my post, bring on if (noteEvent is CommunityPostApprovalEvent) {
if (note.author?.pubkeyHex == myPubKey && note.isNewThread()) { note.replyTo?.filter {
it.isNewThread()
}
} else {
if (note.isNewThread()) {
listOf(note) listOf(note)
} else { } else {
// brings the actual posts, not the approvals null
note.replyTo?.filter { it.isNewThread() } }
} }
} else { } else {
null null
} }
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder) 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.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.utils.mapOfSet
class CommunityFeedFilterSubAssembler( class CommunityFeedFilterSubAssembler(
client: NostrClient, client: NostrClient,
@@ -40,13 +41,33 @@ class CommunityFeedFilterSubAssembler(
return keys.flatMap { return keys.flatMap {
val commEvent = it.community.event val commEvent = it.community.event
if (commEvent is CommunityDefinitionEvent) { if (commEvent is CommunityDefinitionEvent) {
val relays = val communityRelays =
commEvent.relayUrls().ifEmpty { null } (
?: LocalCache.relayHints.hintsForAddress(commEvent.addressTag()).ifEmpty { null } commEvent.relayUrls().ifEmpty {
?: it.community.relayUrls() LocalCache.relayHints.hintsForAddress(commEvent.addressTag())
} + it.community.relayUrls()
).toSet()
relays.toSet().map { val moderatorsOutbox =
filterCommunityPosts(it, commEvent, since?.get(it)?.time) 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 { } else {
emptyList() emptyList()
@@ -20,11 +20,29 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource 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.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl 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.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent 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( fun filterCommunityPosts(
relay: NormalizedRelayUrl, relay: NormalizedRelayUrl,
@@ -35,10 +53,44 @@ fun filterCommunityPosts(
relay = relay, relay = relay,
filter = filter =
Filter( Filter(
authors = community.moderators().map { it.pubKey }.plus(community.pubKey), authors = community.moderatorKeys(),
tags = mapOf("a" to listOf(community.addressTag())), tags = mapOf("a" to listOf(community.addressTag())),
kinds = CommunityPostApprovalEvent.KIND_LIST, kinds = CommunityPostKinds,
limit = 500, limit = 500,
since = since, 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.core.HexKey
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent 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.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent 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.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
@@ -157,7 +161,26 @@ class NotificationFeedFilter(
} }
if (event is BaseThreadedEvent) { 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 isAuthoredPostCited = event.findCitations().any { LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == authorHex }
val isAuthorDirectlyCited = event.citedUsers().contains(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.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
@@ -70,6 +71,7 @@ class UserProfileNewThreadFeedFilter(
it.author == user && it.author == user &&
( (
it.event is TextNoteEvent || it.event is TextNoteEvent ||
it.event is CommentEvent ||
it.event is ClassifiedsEvent || it.event is ClassifiedsEvent ||
it.event is RepostEvent || it.event is RepostEvent ||
it.event is GenericRepostEvent || it.event is GenericRepostEvent ||
@@ -49,6 +49,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note 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.AutoNonlazyGrid
import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.LoadNote 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.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay 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.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.RenderApproveButton
import com.vitorpamplona.amethyst.ui.note.RenderDraft import com.vitorpamplona.amethyst.ui.note.RenderDraft
import com.vitorpamplona.amethyst.ui.note.RenderRepost import com.vitorpamplona.amethyst.ui.note.RenderRepost
import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent 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.EditFieldBorder
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.FeedPadding 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.Size55dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer 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.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.core.Event 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.nip01Core.tags.geohash.geoHashOrScope
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull 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.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoEvent import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
@@ -435,6 +440,7 @@ private fun FullBleedNoteCompose(
) { ) {
val noteEvent = baseNote.event ?: return val noteEvent = baseNote.event ?: return
val isDraft = baseNote.isDraft()
val backgroundColor = val backgroundColor =
calculateBackgroundColor( calculateBackgroundColor(
baseNote.createdAt(), baseNote.createdAt(),
@@ -467,9 +473,21 @@ private fun FullBleedNoteCompose(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
NoteUsernameDisplay(baseNote, Modifier.weight(1f), accountViewModel = accountViewModel) NoteUsernameDisplay(baseNote, Modifier.weight(1f), accountViewModel = accountViewModel)
val isCommunityPost = if (isDraft) {
remember(baseNote) { ObserveDraftEvent(baseNote, accountViewModel) { draftNote ->
baseNote.event?.isTaggedAddressableKind(CommunityDefinitionEvent.KIND) == true 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) { if (isCommunityPost) {
@@ -477,6 +495,7 @@ private fun FullBleedNoteCompose(
} else { } else {
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav) DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
} }
}
CheckAndDisplayEditStatus(editState) CheckAndDisplayEditStatus(editState)
@@ -508,7 +527,7 @@ private fun FullBleedNoteCompose(
DisplayPoW(pow) DisplayPoW(pow)
} }
if (baseNote.isDraft()) { if (isDraft) {
DisplayDraft() DisplayDraft()
} }
@@ -527,9 +546,7 @@ private fun FullBleedNoteCompose(
} }
Row( Row(
modifier = modifier = PaddingHorizontal12Modifier,
modifier
.padding(horizontal = 12.dp),
) { ) {
Column { Column {
if (noteEvent is ChannelCreateEvent) { if (noteEvent is ChannelCreateEvent) {
@@ -753,17 +770,43 @@ private fun FullBleedNoteCompose(
val zapSplits = remember(noteEvent) { noteEvent.hasZapSplitSetup() } val zapSplits = remember(noteEvent) { noteEvent.hasZapSplitSetup() }
if (zapSplits) { if (zapSplits) {
Spacer(modifier = DoubleVertSpacer) Spacer(modifier = DoubleVertSpacer)
Row( Row(PaddingHorizontal12Modifier) {
modifier = Modifier.padding(horizontal = 12.dp),
) {
DisplayZapSplits(noteEvent, false, accountViewModel, nav) DisplayZapSplits(noteEvent, false, accountViewModel, nav)
} }
} }
RenderApprovalIfNeeded(baseNote, accountViewModel, nav)
ReactionsRow(baseNote, showReactionDetail = true, addPadding = true, editState = editState, accountViewModel = accountViewModel, nav = 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 @Composable
private fun RenderClassifiedsReaderForThread( private fun RenderClassifiedsReaderForThread(
noteEvent: ClassifiedsEvent, 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 MaxWidthPaddingTop5dp = Modifier.fillMaxWidth().padding(top = 5.dp)
val VoiceHeightModifier = Modifier.fillMaxWidth().height(100.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="blocked_users">Blocked Users</string>
<string name="new_threads">New Threads</string> <string name="new_threads">New Threads</string>
<string name="conversations">Conversations</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="notes">Notes</string>
<string name="replies">Replies</string> <string name="replies">Replies</string>
<string name="mutual">Yours</string> <string name="mutual">Yours</string>
@@ -806,6 +808,10 @@
<string name="created_at">Created at</string> <string name="created_at">Created at</string>
<string name="rules">Rules</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="login_with_external_signer">Login with Amber</string>
<string name="status_update">Update your status</string> <string name="status_update">Update your status</string>
@@ -81,50 +81,13 @@ fun TranslatableRichTextViewer(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
var translatedTextState by translateAndWatchLanguageChanges(content, id, accountViewModel) TranslatableRichTextViewer(
CrossfadeIfEnabled(targetState = translatedTextState, accountViewModel = accountViewModel) {
RenderText(
translatedTextState = it,
content = content, content = content,
canPreview = canPreview,
quotesLeft = quotesLeft,
modifier = modifier,
tags = tags,
backgroundColor = backgroundColor,
id = id, id = id,
callbackUri = callbackUri,
accountViewModel = accountViewModel, 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 {
ExpandableRichTextViewer( ExpandableRichTextViewer(
toBeViewed, it,
canPreview, canPreview,
quotesLeft, quotesLeft,
modifier, modifier,
@@ -135,6 +98,45 @@ private fun RenderText(
accountViewModel, accountViewModel,
nav, 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 ( if (
translatedTextState.sourceLang != null && translatedTextState.sourceLang != null &&
@@ -325,7 +327,7 @@ fun translateAndWatchLanguageChanges(
id: String, id: String,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
): MutableState<TranslationConfig> { ): MutableState<TranslationConfig> {
var translatedTextState = remember(id) { mutableStateOf(TranslationsCache.get(content)) } val translatedTextState = remember(id) { mutableStateOf(TranslationsCache.get(content)) }
TranslateAndWatchLanguageChanges( TranslateAndWatchLanguageChanges(
content, content,
@@ -32,8 +32,12 @@ fun Event.isTaggedAddressableNotes(addressIds: Set<String>) = tags.isTaggedAddre
fun Event.isTaggedAddressableKind(kind: Int) = tags.isTaggedAddressableKind(kind) 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(kind: Int) = tags.getTagOfAddressableKind(kind)
fun Event.getTagOfAddressableKind(kindStr: String) = tags.getTagOfAddressableKind(kindStr)
fun Event.taggedATags() = tags.taggedATags() fun Event.taggedATags() = tags.taggedATags()
fun Event.firstTaggedATag() = tags.firstTaggedATag() 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(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(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.taggedATags() = this.mapNotNull(ATag::parse)
fun TagArray.firstTaggedATag() = this.fastFirstNotNullOfOrNull(ATag::parse) fun TagArray.firstTaggedATag() = this.fastFirstNotNullOfOrNull(ATag::parse)
@@ -72,16 +72,18 @@ open class BaseThreadedEvent(
return newStyleReply ?: newStyleRoot ?: oldStylePositional return newStyleReply ?: newStyleRoot ?: oldStylePositional
} }
fun tagsWithoutCitations(): List<String> { open fun tagsWithoutCitations(): List<String> {
val certainRepliesTo = markedReplyTos() val certainRepliesTo = markedReplyTos()
val uncertainRepliesTo = unmarkedReplyTos() val uncertainRepliesTo = unmarkedReplyTos()
val tagAddresses = val tagAddresses =
taggedATags() taggedATags()
.filter { .filter { aTag ->
it.kind != CommunityDefinitionEvent.KIND && (kind != WikiNoteEvent.KIND || it.kind != WikiNoteEvent.KIND) aTag.kind != CommunityDefinitionEvent.KIND && (kind != WikiNoteEvent.KIND || aTag.kind != WikiNoteEvent.KIND)
// removes forks from itself. // removes forks from itself.
}.map { it.toTag() } }.map {
it.toTag()
}
if (certainRepliesTo.isEmpty() && uncertainRepliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList() 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.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder 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.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider 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.EventIdHint
import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate 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.nip01Core.tags.geohash.GeoHashTag
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag 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.nip22Comments.tags.RootKindTag
import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull
import kotlin.collections.mapNotNull
@Immutable @Immutable
class CommentEvent( class CommentEvent(
@@ -126,6 +130,28 @@ class CommentEvent(
return aHints + qHints + nip19Hints 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 rootAuthor() = tags.firstNotNullOfOrNull(RootAuthorTag::parse)
fun replyAuthor() = tags.firstNotNullOfOrNull(ReplyAuthorTag::parse) fun replyAuthor() = tags.firstNotNullOfOrNull(ReplyAuthorTag::parse)
@@ -142,14 +168,6 @@ class CommentEvent(
fun replyAuthorHints() = tags.mapNotNull(ReplyAuthorTag::parseAsHint) 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 */ /** root and reply scope search */
fun isTaggedScope(scopeId: String) = tags.any { RootIdentifierTag.isTagged(it, scopeId) || ReplyIdentifierTag.isTagged(it, scopeId) } 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 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) } 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(ReplyEventTag::parseKey)
?: tags.lastNotNullOfOrNull(RootEventTag::parseKey) ?: tags.lastNotNullOfOrNull(RootEventTag::parseKey)
fun rootAddress() = tags.mapNotNull(RootAddressTag::parseAddress)
fun rootAddressId() = tags.mapNotNull(RootAddressTag::parseAddressId)
fun replyingToAddressId(): String? = 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 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 { companion object {
const val KIND = 1111 const val KIND = 1111
const val ALT = "Reply to " const val ALT = "Reply to "
@@ -44,6 +44,12 @@ class ReplyAddressTag(
@JvmStatic @JvmStatic
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() 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 @JvmStatic
fun parse(tag: Array<String>): ReplyAddressTag? { fun parse(tag: Array<String>): ReplyAddressTag? {
ensure(tag.has(1)) { return null } ensure(tag.has(1)) { return null }
@@ -55,6 +61,15 @@ class ReplyAddressTag(
return ReplyAddressTag(tag[1], relayHint) 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 @JvmStatic
fun parseAddressId(tag: Array<String>): String? { fun parseAddressId(tag: Array<String>): String? {
ensure(tag.has(1)) { return null } ensure(tag.has(1)) { return null }
@@ -67,6 +67,15 @@ class RootAddressTag(
return RootAddressTag(tag[1], relayHint) 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 @JvmStatic
fun parseValidAddress(tag: Array<String>): String? { fun parseValidAddress(tag: Array<String>): String? {
ensure(tag.has(1)) { return null } 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.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder 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.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag.Companion.isTagged
import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedATags import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
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.kinds.kind import com.vitorpamplona.quartz.nip01Core.tags.kinds.kind
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag 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.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.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.tags.CommunityTag
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.collections.mapNotNull
@Immutable @Immutable
class CommunityPostApprovalEvent( class CommunityPostApprovalEvent(
@@ -54,13 +61,17 @@ class CommunityPostApprovalEvent(
EventHintProvider, EventHintProvider,
AddressHintProvider, AddressHintProvider,
PubKeyHintProvider { 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) override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
@@ -77,15 +88,17 @@ class CommunityPostApprovalEvent(
null 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 { companion object {
const val KIND = 4550 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 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) fun relays() = tags.mapNotNull(RelayTag::parse)
@@ -88,6 +88,7 @@ class CommunityDefinitionEvent(
companion object { companion object {
const val KIND = 34550 const val KIND = 34550
const val KIND_STR = "34550"
const val ALT_DESCRIPTION = "Community definition" const val ALT_DESCRIPTION = "Community definition"
fun build( 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.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address 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.arrayOfNotNull
import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.ensure import com.vitorpamplona.quartz.utils.ensure
@@ -47,7 +48,7 @@ class CommunityTag(
const val TAG_NAME = "a" const val TAG_NAME = "a"
@JvmStatic @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 @JvmStatic
fun isTagged( fun isTagged(
@@ -61,12 +62,6 @@ class CommunityTag(
address: CommunityTag, address: CommunityTag,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == address.toTag() ) = 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 @JvmStatic
fun isTaggedWithKind( fun isTaggedWithKind(
tag: Array<String>, tag: Array<String>,
@@ -74,26 +69,27 @@ class CommunityTag(
) = tag.has(1) && tag[0] == TAG_NAME && Address.isOfKind(tag[1], kind) ) = tag.has(1) && tag[0] == TAG_NAME && Address.isOfKind(tag[1], kind)
@JvmStatic @JvmStatic
fun parse( fun isIn(
aTagId: String, tag: Array<String>,
relay: String?, addressIds: Set<String>,
) = Address.parse(aTagId)?.let { ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in addressIds
CommunityTag(it, relay?.let { RelayUrlNormalizer.normalizeOrNull(it) })
}
@JvmStatic @JvmStatic
fun parse(tag: Array<String>): CommunityTag? { fun parse(tag: Array<String>): CommunityTag? {
ensure(tag.has(1)) { return null } ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null } ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null } ensure(Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null }
return parse(tag[1], tag.getOrNull(2))
val address = Address.parse(tag[1]) ?: return null
val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }
return CommunityTag(address, relayHint)
} }
@JvmStatic @JvmStatic
fun parseValidAddress(tag: Array<String>): String? { fun parseValidAddress(tag: Array<String>): String? {
ensure(tag.has(1)) { return null } ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null } ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null } ensure(Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null }
return Address.parse(tag[1])?.toValue() return Address.parse(tag[1])?.toValue()
} }
@@ -102,14 +98,16 @@ class CommunityTag(
ensure(tag.has(1)) { return null } ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null } ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { 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 @JvmStatic
fun parseAddressId(tag: Array<String>): String? { fun parseAddressId(tag: Array<String>): String? {
ensure(tag.has(1)) { return null } ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null } ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null } ensure(Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null }
return tag[1] return tag[1]
} }
@@ -117,8 +115,7 @@ class CommunityTag(
fun parseAsHint(tag: Array<String>): AddressHint? { fun parseAsHint(tag: Array<String>): AddressHint? {
ensure(tag.has(2)) { return null } ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { 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 }
ensure(tag[1].contains(':')) { return null }
ensure(tag[2].isNotEmpty()) { return null } ensure(tag[2].isNotEmpty()) { return null }
val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2])