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 c5c576b4a..8409dd907 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.relationshipStatus.RelationshipStatusEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.checkSignature @@ -84,7 +85,7 @@ import com.vitorpamplona.quartz.nip03Timestamp.VerificationState import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex -import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent @@ -535,6 +536,12 @@ object LocalCache : ILocalCache { wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) + fun consume( + event: PublicMessageEvent, + relay: NormalizedRelayUrl? = null, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + fun consume( event: TorrentEvent, relay: NormalizedRelayUrl?, @@ -575,7 +582,7 @@ object LocalCache : ILocalCache { // Already processed this event. if (note.event != null) return false - if (event is BaseThreadedEvent && antiSpam.isSpam(event, relay)) { + if (event is BaseNoteEvent && antiSpam.isSpam(event, relay)) { return false } @@ -2813,6 +2820,7 @@ object LocalCache : ILocalCache { is PrivateOutboxRelayListEvent -> consume(event, relay, wasVerified) is ProxyRelayListEvent -> consume(event, relay, wasVerified) is PinListEvent -> consume(event, relay, wasVerified) + is PublicMessageEvent -> consume(event, relay, wasVerified) is PeopleListEvent -> consume(event, relay, wasVerified) is PollNoteEvent -> consume(event, relay, wasVerified) is ReactionEvent -> consume(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index 60b337764..6da7409fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01N import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -63,6 +64,7 @@ val NotificationsPerKeyKinds = EphemeralChatEvent.KIND, BadgeAwardEvent.KIND, PollNoteEvent.KIND, + PublicMessageEvent.KIND, ) val NotificationsPerKeyKinds2 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt index 21b3ce2ff..dd1cf3e62 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -69,7 +70,7 @@ val SearchPostsByTextKinds2 = EmojiPackEvent.KIND, HighlightEvent.KIND, LiveActivitiesEvent.KIND, - PollNoteEvent.KIND, + PublicMessageEvent.KIND, NNSEvent.KIND, WikiNoteEvent.KIND, CommentEvent.KIND, 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 71898328a..7a887efe3 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 @@ -119,12 +119,14 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage +import com.vitorpamplona.amethyst.ui.note.types.RenderPublicMessage import com.vitorpamplona.amethyst.ui.note.types.RenderReaction import com.vitorpamplona.amethyst.ui.note.types.RenderReport import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment +import com.vitorpamplona.amethyst.ui.note.types.RenderVoiceTrack import com.vitorpamplona.amethyst.ui.note.types.RenderWikiContent import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -160,6 +162,7 @@ import com.vitorpamplona.quartz.experimental.forks.isAFork import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent 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 @@ -214,6 +217,7 @@ import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import kotlinx.coroutines.delay @Composable @@ -790,7 +794,7 @@ private fun RenderNoteRow( is VideoHorizontalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav) is VideoVerticalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav) is PictureEvent -> PictureDisplay(baseNote, true, ContentScale.FillWidth, PaddingValues(vertical = 5.dp), backgroundColor, accountViewModel, nav) - + is BaseVoiceEvent -> RenderVoiceTrack(baseNote, ContentScale.FillWidth, accountViewModel, nav) is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, true, ContentScale.FillWidth, accountViewModel) is CommunityPostApprovalEvent -> { RenderPostApproval( @@ -865,7 +869,16 @@ private fun RenderNoteRow( accountViewModel, nav, ) - + is PublicMessageEvent -> + RenderPublicMessage( + baseNote, + makeItShort, + canPreview, + quotesLeft, + backgroundColor, + accountViewModel, + nav, + ) else -> { RenderTextEvent( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicMessage.kt new file mode 100644 index 000000000..69dd2052c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicMessage.kt @@ -0,0 +1,88 @@ +/** + * 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.note.types + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags +import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists + +@Composable +fun RenderPublicMessage( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? PublicMessageEvent ?: return + + if (makeItShort && accountViewModel.isLoggedUser(note.author)) { + Text( + text = noteEvent.content, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + val callbackUri = remember(note) { note.toNostrUri() } + + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + TranslatableRichTextViewer( + content = noteEvent.content, + canPreview = canPreview && !makeItShort, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth(), + tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() }, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (noteEvent.hasHashtags()) { + DisplayUncitedHashtags(noteEvent, noteEvent.content, callbackUri, accountViewModel, nav) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index eb67689f1..ec309dcfd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -52,7 +52,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.core.util.Consumer -import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note @@ -173,7 +172,7 @@ private fun NewPostScreenInner( onPost = { // uses the accountViewModel scope to avoid cancelling this // function when the postViewModel is released - accountViewModel.viewModelScope.launch(Dispatchers.IO) { + accountViewModel.runIOCatching { postViewModel.sendPostSync() delay(100) nav.popBack() @@ -182,7 +181,7 @@ private fun NewPostScreenInner( onCancel = { // uses the accountViewModel scope to avoid cancelling this // function when the postViewModel is released - accountViewModel.viewModelScope.launch(Dispatchers.IO) { + accountViewModel.runIOCatching { postViewModel.sendDraftSync() nav.popBack() postViewModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 49cb45da9..30d88edf5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -136,6 +136,9 @@ open class ShortNotePostViewModel : IZapRaiser { val draftTag = DraftTagState() + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account + init { viewModelScope.launch(Dispatchers.IO) { draftTag.versions.collectLatest { @@ -147,9 +150,6 @@ open class ShortNotePostViewModel : } } - var accountViewModel: AccountViewModel? = null - var account: Account? = null - var originalNote: Note? by mutableStateOf(null) var forkedFromNote: Note? by mutableStateOf(null) @@ -157,8 +157,7 @@ open class ShortNotePostViewModel : var eTags by mutableStateOf?>(null) val iMetaAttachments = IMetaAttachments() - var nip95attachments by - mutableStateOf>>(emptyList()) + var nip95attachments by mutableStateOf>>(emptyList()) override var message by mutableStateOf(TextFieldValue("")) @@ -213,11 +212,11 @@ open class ShortNotePostViewModel : var wantsZapRaiser by mutableStateOf(false) override val zapRaiserAmount = mutableStateOf(null) - fun lnAddress(): String? = account?.userProfile()?.info?.lnAddress() + fun lnAddress(): String? = account.userProfile().info?.lnAddress() - fun hasLnAddress(): Boolean = account?.userProfile()?.info?.lnAddress() != null + fun hasLnAddress(): Boolean = account.userProfile().info?.lnAddress() != null - fun user(): User? = account?.userProfile() + fun user(): User? = account.userProfile() open fun init(accountVM: AccountViewModel) { this.accountViewModel = accountVM @@ -239,7 +238,6 @@ open class ShortNotePostViewModel : version: Note?, draft: Note?, ) { - val accountViewModel = accountViewModel ?: return val noteEvent = draft?.event val noteAuthor = draft?.author @@ -369,8 +367,8 @@ open class ShortNotePostViewModel : } private fun loadFromDraft(draftEvent: TextNoteEvent) { - canAddInvoice = accountViewModel?.userProfile()?.info?.lnAddress() != null - canAddZapRaiser = accountViewModel?.userProfile()?.info?.lnAddress() != null + canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null + canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null multiOrchestrator = null val localForwardZapTo = draftEvent.tags.filter { it.size > 1 && it[0] == "zap" } @@ -475,15 +473,15 @@ open class ShortNotePostViewModel : } } - accountViewModel?.account?.signAndComputeBroadcast(template, extraNotesToBroadcast) + accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) - accountViewModel?.deleteDraft(draftTag.current) + accountViewModel.deleteDraft(draftTag.current) cancel() } suspend fun sendDraftSync() { - val accountViewModel = accountViewModel ?: return + val accountViewModel = accountViewModel if (message.text.isBlank()) { accountViewModel.account.deleteDraft(draftTag.current) @@ -492,24 +490,19 @@ open class ShortNotePostViewModel : accountViewModel.account.createAndSendDraft(draftTag.current, template) nip95attachments.forEach { - account?.sendToPrivateOutboxAndLocal(it.first) - account?.sendToPrivateOutboxAndLocal(it.second) + account.sendToPrivateOutboxAndLocal(it.first) + account.sendToPrivateOutboxAndLocal(it.second) } } } private suspend fun createTemplate(): EventTemplate? { - if (accountViewModel == null) { - cancel() - return null - } - val tagger = NewMessageTagger( message.text, pTags, eTags, - accountViewModel!!, + accountViewModel, ) tagger.run() @@ -518,7 +511,7 @@ open class ShortNotePostViewModel : val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() val localZapRaiserAmount = if (wantsZapRaiser) zapRaiserAmount.value else null - val emojis = findEmoji(tagger.message, account?.emoji?.myEmojis?.value) + val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value) val urls = findURLs(tagger.message) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) @@ -607,8 +600,6 @@ open class ShortNotePostViewModel : context: Context, ) { viewModelScope.launch(Dispatchers.Default) { - val myAccount = account ?: return@launch - val myMultiOrchestrator = multiOrchestrator ?: return@launch isUploadingImage = true @@ -619,16 +610,16 @@ open class ShortNotePostViewModel : contentWarningReason, MediaCompressor.intToCompressorQuality(mediaQuality), server, - myAccount, + account, context, ) if (results.allGood) { results.successful.forEach { state -> if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { - val nip95 = myAccount.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason) + val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason) nip95attachments = nip95attachments + nip95 - val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } + val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } note?.let { message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) @@ -786,8 +777,7 @@ open class ShortNotePostViewModel : item.link.url, ) { Amethyst.instance.okHttpClients.getHttpClient( - accountViewModel?.account?.privacyState?.shouldUseTorForImageDownload(item.link.url) - ?: false, + accountViewModel.account.privacyState.shouldUseTorForImageDownload(item.link.url), ) } } @@ -870,7 +860,7 @@ open class ShortNotePostViewModel : override fun updateZapFromText() { viewModelScope.launch(Dispatchers.Default) { val tagger = - NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel!!) + NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) tagger.run() tagger.pTags?.forEach { taggedUser -> if (!forwardZapTo.value.items.any { it.key == taggedUser }) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt index 806516e96..a92e45871 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent @@ -98,6 +99,7 @@ class HomeNewThreadFeedFilter( (noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || + (noteEvent is PublicMessageEvent && noteEvent.content.isNotEmpty() && noteEvent.isIncluded(account.signer.pubKey)) || noteEvent is InteractiveStoryPrologueEvent || noteEvent is CommentEvent || noteEvent is AudioTrackEvent || 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 63861a764..06841d421 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 @@ -148,6 +148,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage +import com.vitorpamplona.amethyst.ui.note.types.RenderPublicMessage import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent @@ -184,6 +185,7 @@ import com.vitorpamplona.quartz.experimental.forks.forkFromAddress import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent 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.core.Event import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableKind @@ -642,6 +644,8 @@ private fun FullBleedNoteCompose( RenderDraft(baseNote, 3, true, backgroundColor, accountViewModel, nav) } else if (noteEvent is HighlightEvent) { RenderHighlight(baseNote, false, canPreview, quotesLeft = 3, backgroundColor, accountViewModel, nav) + } else if (noteEvent is PublicMessageEvent) { + RenderPublicMessage(baseNote, false, canPreview, quotesLeft = 3, backgroundColor, accountViewModel, nav) } else if (noteEvent is CommentEvent) { RenderTextEvent( baseNote, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 40d6c2bf4..3ccb82c9b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1214,6 +1214,7 @@ Log off on device lock Private Message + Public Message Chat Relay The relay that all users of this chat connect to diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt index 82a941199..4477dfe7d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.relationshipStatus.RelationshipStatusEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.Event @@ -267,6 +268,7 @@ class EventFactory { PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) PrivateOutboxRelayListEvent.KIND -> PrivateOutboxRelayListEvent(id, pubKey, createdAt, tags, content, sig) ProxyRelayListEvent.KIND -> ProxyRelayListEvent(id, pubKey, createdAt, tags, content, sig) + PublicMessageEvent.KIND -> PublicMessageEvent(id, pubKey, createdAt, tags, content, sig) ReactionEvent.KIND -> ReactionEvent(id, pubKey, createdAt, tags, content, sig) RelationshipStatusEvent.KIND -> RelationshipStatusEvent(id, pubKey, createdAt, tags, content, sig) RelayAuthEvent.KIND -> RelayAuthEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/PublicMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/PublicMessageEvent.kt new file mode 100644 index 000000000..f688b42df --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/PublicMessageEvent.kt @@ -0,0 +1,96 @@ +/** + * 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.experimental.publicMessages + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.publicMessages.tags.ReceiverTag +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.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class PublicMessageEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider, + SearchableEvent { + override fun indexableContent() = content + + override fun pubKeyHints() = tags.mapNotNull(ReceiverTag::parseAsHint) + citedNIP19().pubKeyHints() + + override fun linkedPubKeys() = tags.mapNotNull(ReceiverTag::parseKey) + citedNIP19().pubKeys() + + fun isIncluded(pubKey: HexKey) = tags.any(ReceiverTag::match, pubKey) || this.pubKey == pubKey + + fun group() = tags.mapNotNullTo(mutableListOf(ReceiverTag(this.pubKey, null)), ReceiverTag::parse) + + fun groupKeys() = tags.mapNotNullTo(mutableListOf(this.pubKey), ReceiverTag::parseKey) + + fun groupKeySet() = tags.mapNotNullTo(mutableSetOf(this.pubKey), ReceiverTag::parseKey) + + companion object { + const val KIND = 24 + const val ALT_DESCRIPTION = "Public Message" + + fun build( + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + initializer() + } + + fun build( + to: ReceiverTag, + msg: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, msg, createdAt) { + alt(ALT_DESCRIPTION) + toUser(to) + initializer() + } + + fun build( + to: List, + msg: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, msg, createdAt) { + alt(ALT_DESCRIPTION) + toGroup(to) + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/TagArrayBuilderExt.kt new file mode 100644 index 000000000..9370494dd --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/TagArrayBuilderExt.kt @@ -0,0 +1,28 @@ +/** + * 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.experimental.publicMessages + +import com.vitorpamplona.quartz.experimental.publicMessages.tags.ReceiverTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.toGroup(list: List) = addAll(list.map { it.toTagArray() }) + +fun TagArrayBuilder.toUser(user: ReceiverTag) = add(user.toTagArray()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/tags/ReceiverTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/tags/ReceiverTag.kt new file mode 100644 index 000000000..7378e2086 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/tags/ReceiverTag.kt @@ -0,0 +1,95 @@ +/** + * 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.experimental.publicMessages.tags + +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.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +class ReceiverTag( + val pubKey: HexKey, + val relayHint: NormalizedRelayUrl? = null, +) { + fun toNProfile(): String = NProfile.create(pubKey, relayHint?.let { listOf(it) } ?: emptyList()) + + fun toNPub(): String = pubKey.hexToByteArray().toNpub() + + fun toTagArray() = assemble(pubKey, relayHint) + + fun toTagIdOnly() = assemble(pubKey, null) + + companion object Companion { + const val TAG_NAME = "p" + + fun isTagged(tag: Array): Boolean = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 + + fun match( + tag: Array, + key: HexKey, + ): Boolean = tag.has(1) && tag[0] == TAG_NAME && tag[1] == key + + @JvmStatic + fun parse(tag: Tag): ReceiverTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ReceiverTag(tag[1], hint) + } + + @JvmStatic + fun parseKey(tag: Tag): HexKey? { + 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 parseAsHint(tag: Tag): PubKeyHint? { + 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 hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + + ensure(hint != null) { return null } + + return PubKeyHint(tag[1], hint) + } + + @JvmStatic + fun assemble( + pubkey: HexKey, + relayHint: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseNoteEvent.kt new file mode 100644 index 000000000..8b02fc2bb --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseNoteEvent.kt @@ -0,0 +1,103 @@ +/** + * 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.nip10Notes + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip10Notes.content.findIndexTagsWithEventsOrAddresses +import com.vitorpamplona.quartz.nip10Notes.content.findIndexTagsWithPeople +import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris +import com.vitorpamplona.quartz.nip19Bech32.entities.Entity +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub + +@Immutable +open class BaseNoteEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + @Transient + private var citedUsersCache: Set? = null + + @Transient + private var citedNotesCache: Set? = null + + @Transient + private var citedNIP19Cache: List? = null + + fun citedNIP19(): List { + citedNIP19Cache?.let { + return it + } + + return findNostrUris(content).also { citedNIP19Cache = it } + } + + fun citedUsers(): Set { + citedUsersCache?.let { + return it + } + + val citedUsers = mutableSetOf() + + findIndexTagsWithPeople(content, tags, citedUsers) + citedNIP19().forEach { parsed -> + when (parsed) { + is NProfile -> citedUsers.add(parsed.hex) + is NPub -> citedUsers.add(parsed.hex) + } + } + + citedUsersCache = citedUsers + return citedUsers + } + + fun findCitations(): Set { + citedNotesCache?.let { + return it + } + + val citations = mutableSetOf() + + findIndexTagsWithEventsOrAddresses(content, tags, citations).toMutableSet() + citedNIP19().forEach { entity -> + when (entity) { + is NEvent -> citations.add(entity.hex) + is NAddress -> citations.add(entity.aTag()) + is NNote -> citations.add(entity.hex) + is NEmbed -> citations.add(entity.event.id) + } + } + + citedNotesCache = citations + return citations + } +} 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 d00610eae..072cf6fbe 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt @@ -21,21 +21,10 @@ package com.vitorpamplona.quartz.nip10Notes import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedATags import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers -import com.vitorpamplona.quartz.nip10Notes.content.findIndexTagsWithEventsOrAddresses -import com.vitorpamplona.quartz.nip10Notes.content.findIndexTagsWithPeople -import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag -import com.vitorpamplona.quartz.nip19Bech32.entities.Entity -import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress -import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed -import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent -import com.vitorpamplona.quartz.nip19Bech32.entities.NNote -import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile -import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull @@ -49,16 +38,7 @@ open class BaseThreadedEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient - private var citedUsersCache: Set? = null - - @Transient - private var citedNotesCache: Set? = null - - @Transient - private var citedNIP19Cache: List? = null - +) : BaseNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) { fun mentions() = taggedUsers() fun markedRoot() = tags.firstNotNullOfOrNull(MarkedETag::parseRoot) @@ -92,54 +72,6 @@ open class BaseThreadedEvent( return newStyleReply ?: newStyleRoot ?: oldStylePositional } - fun citedNIP19(): List { - citedNIP19Cache?.let { - return it - } - - return findNostrUris(content).also { citedNIP19Cache = it } - } - - fun citedUsers(): Set { - citedUsersCache?.let { - return it - } - - val citedUsers = mutableSetOf() - - findIndexTagsWithPeople(content, tags, citedUsers) - citedNIP19().forEach { parsed -> - when (parsed) { - is NProfile -> citedUsers.add(parsed.hex) - is NPub -> citedUsers.add(parsed.hex) - } - } - - citedUsersCache = citedUsers - return citedUsers - } - - fun findCitations(): Set { - citedNotesCache?.let { - return it - } - - val citations = mutableSetOf() - - findIndexTagsWithEventsOrAddresses(content, tags, citations).toMutableSet() - citedNIP19().forEach { entity -> - when (entity) { - is NEvent -> citations.add(entity.hex) - is NAddress -> citations.add(entity.aTag()) - is NNote -> citations.add(entity.hex) - is NEmbed -> citations.add(entity.event.id) - } - } - - citedNotesCache = citations - return citations - } - fun tagsWithoutCitations(): List { val certainRepliesTo = markedReplyTos() val uncertainRepliesTo = unmarkedReplyTos()