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