From 69b100187398ce94fbcc8f3054d20e231cf6861b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 30 Sep 2026 22:41:14 -0400 Subject: [PATCH 1/3] fix(buzz): Buzz Desktop interop fixes from a live Buzz <-> Amethyst test - Attachments in Buzz channels/threads upload to the workspace's own media server (https:///upload): server-scoped 55s token, X-SHA-256 header, metadata stripped to Buzz's validator (EXIF/ICC/PNG text), no /media optimization, no fallback or mirroring to public servers. imeta keys Buzz rejects (ox, content-warning, ...) are dropped. - A relay's OK false is now tracked and shown ("The relay refused this message" + its reason) instead of pending forever. - Mentions on Buzz are written as plain @Name + p tag (Buzz showed the raw nostr:nprofile); thread replies get the same. - The account's kind-0 is pushed to a Buzz workspace relay when a channel opens, so Buzz can name, find and add the user. - Learn a relay's filter cap from Buzz's NOTICE ("REQ contains 12 filters, maximum is 10") and resend the over-cap subscription under it. - Open Buzz channels show Join to non-members, Leave only to members, and posting joins first, matching Buzz's own client. - Thread composer sends thread-scoped typing; new forums open on their thread list. - Previews and notification cards show the latest edit with mentions as names; notification cards drop reactions that were deleted. - NIP-19 references embedded in a URL (blossom npub subdomains) are no longer cited/tagged. Co-Authored-By: Claude Opus 5.5 --- .../service/uploads/AndroidMediaUploader.kt | 108 ++++++++++- .../uploads/blossom/BlossomUploader.kt | 5 + .../uploads/hls/HlsBlobUploaderFactory.kt | 4 + .../ui/screen/loggedIn/buzz/BuzzImportRow.kt | 5 +- .../buzz/ForumReplyNewMessageViewModel.kt | 8 +- .../loggedIn/chats/feed/ChatDeliveryTicks.kt | 46 +++-- .../loggedIn/chats/feed/ChatMessageCompose.kt | 32 ++-- .../loggedIn/chats/minichat/MinichatScreen.kt | 37 +++- .../relayGroup/RelayGroupChannelListScreen.kt | 5 +- .../relayGroup/RelayGroupMetadataScreen.kt | 12 +- .../relayGroup/RelayGroupTopBar.kt | 29 ++- .../send/ChannelFileUploadDialog.kt | 4 +- .../send/ChannelNewMessageViewModel.kt | 40 +++- .../chats/rooms/ChatroomHeaderCompose.kt | 8 +- .../chats/utils/ChatFileUploadDialog.kt | 26 +-- .../chats/utils/ChatFileUploadState.kt | 16 +- .../amethyst/commons/model/Account.kt | 9 +- .../commons/model/AccountRelayGroupActions.kt | 33 +++- .../model/composer/NewMessageTagger.kt | 23 ++- .../commons/model/mediaServers/ServerName.kt | 16 ++ .../notifications/CardFeedContentState.kt | 10 +- .../chatDelivery/ChatDeliveryTracker.kt | 39 +++- .../ChatDeliveryTrackerRejectionTest.kt | 81 ++++++++ .../composer/NewMessageTaggerKeyParseTest.kt | 25 +++ .../composeResources/values/strings.xml | 1 + .../amethyst/commons/ui/note/types/Text.kt | 27 ++- .../viewmodels/AccountFeedContentStates.kt | 2 + .../commons/viewmodels/AccountViewModel.kt | 11 +- .../quartz/buzz/media/BuzzImeta.kt | 48 +++++ .../quartz/buzz/media/BuzzMediaSanitizer.kt | 176 ++++++++++++++++++ .../RelayInsertConfirmationCollector.kt | 12 +- .../relay/client/pool/PoolRequests.kt | 24 +++ .../relay/client/pool/RelayReqRefusals.kt | 26 ++- .../quartz/nip10Notes/content/NostrUris.kt | 3 +- .../quartz/nip19Bech32/Nip19Parser.kt | 33 +++- .../nipB7Blossom/BlossomAuthorizationEvent.kt | 10 +- .../quartz/buzz/media/BuzzImetaTest.kt | 67 +++++++ .../buzz/media/BuzzMediaSanitizerTest.kt | 94 ++++++++++ .../client/pool/PoolRequestsFilterCapTest.kt | 37 ++++ .../nip10Notes/content/FindNostrUrisTest.kt | 49 +++++ 40 files changed, 1145 insertions(+), 96 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/chatDelivery/ChatDeliveryTrackerRejectionTest.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImeta.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizer.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImetaTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizerTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/content/FindNostrUrisTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt index 2e670493a4..c1b538b076 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt @@ -40,12 +40,14 @@ import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState.UploadingFinalState import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader +import com.vitorpamplona.quartz.buzz.media.BuzzMediaSanitizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.ciphers.NostrCipher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -243,6 +245,31 @@ class AndroidMediaUploader( return firstError!!.also { updateState(0.0, it) } } + private class SanitizedBlob( + val file: File, + val uri: Uri, + val size: Long, + ) + + /** + * A copy of [uri] without the metadata a Buzz workspace refuses (HTTP 422: EXIF/XMP/ICC + * segments, PNG text chunks, trailing bytes), or null when there was nothing to remove or + * the format isn't one [BuzzMediaSanitizer] rewrites. The caller deletes the file. + */ + private fun sanitizeForBuzzWorkspace( + uri: Uri, + contentType: String?, + context: Context, + ): SanitizedBlob? { + if (!BuzzMediaSanitizer.handles(contentType)) return null + val original = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return null + val clean = BuzzMediaSanitizer.sanitize(original, contentType) + if (clean.contentEquals(original)) return null + val file = File.createTempFile("buzz-upload-", ".bin", context.cacheDir) + file.writeBytes(clean) + return SanitizedBlob(file, Uri.fromFile(file), clean.size.toLong()) + } + /** * The upload token for one blob, signed at most once and reused by every server tried. * A signer that returned no token is asked again on the next server; only a token or a @@ -279,11 +306,13 @@ class AndroidMediaUploader( forcedSigner: NostrSigner?, context: Context, sharedAuth: SharedUploadAuth, + buzzWorkspace: Boolean = false, ): UploadingFinalState { updateState(0.2, UploadingState.Uploading) // BUD-05: route through /media (optimize) when the user opted in. The forced-signer - // path (e.g. NIP-46 draft signing) always uses the bit-exact /upload. - val useMedia = forcedSigner == null && account.settings.optimizeMediaOnUpload.value + // path (e.g. NIP-46 draft signing) always uses the bit-exact /upload, and so does a + // Buzz workspace, whose relay checks the imeta `x` against the blob it stored. + val useMedia = !buzzWorkspace && forcedSigner == null && account.settings.optimizeMediaOnUpload.value return try { val result = BlossomUploader() @@ -303,6 +332,20 @@ class AndroidMediaUploader( httpAuth = { hash, size, alt -> sharedAuth.get { when { + // Buzz's strict mode demands exactly one `server` tag naming the + // workspace and a token that expires within 60s of signing. + buzzWorkspace -> { + val now = TimeUtils.now() + BlossomAuthorizationEvent.createUploadAuth( + hash = hash, + size = size, + alt = alt, + signer = forcedSigner ?: account.signer, + servers = listOf(serverBaseUrl), + createdAt = now, + expiration = now + BUZZ_UPLOAD_TOKEN_SECS, + ) + } forcedSigner != null -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner) useMedia -> account.createBlossomMediaAuth(hash, size, alt) else -> account.createBlossomUploadAuth(hash, size, alt) @@ -314,19 +357,26 @@ class AndroidMediaUploader( ) val finalState = - verifyHeader( - uploadResult = result, - localContentType = contentType, - okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, - originalHash = originalHash, - originalContentType = contentTypeForResult, - ) + if (buzzWorkspace) { + // The workspace already bound the stored blob to our X-SHA-256 and the token's + // `x`, and its /media reads need a signed GET this client doesn't send — so take + // its descriptor rather than downloading the blob back to hash it again. + trustWorkspaceDescriptor(result, contentType, size, contentTypeForResult, originalHash) + } else { + verifyHeader( + uploadResult = result, + localContentType = contentType, + okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, + originalHash = originalHash, + originalContentType = contentTypeForResult, + ) + } // BUD-04: replicate the blob to the user's other Blossom servers for redundancy. // Fire-and-forget on the account scope AFTER the upload is finished: mirroring is // pure background redundancy, so it must never delay, alter, or fail the upload the // user already completed, and must not touch the on-screen progress state. - if (finalState is UploadingState.Finished && forcedSigner == null && account.settings.mirrorUploadsToAllServers.value) { + if (finalState is UploadingState.Finished && !buzzWorkspace && forcedSigner == null && account.settings.mirrorUploadsToAllServers.value) { account.scope.launch(Dispatchers.IO) { try { mirrorToOtherServers(result, serverBaseUrl, account) @@ -389,6 +439,27 @@ class AndroidMediaUploader( } } + private fun trustWorkspaceDescriptor( + uploadResult: MediaUploadResult, + localContentType: String?, + localSize: Long?, + originalContentType: String?, + originalHash: String?, + ): UploadingFinalState { + val url = uploadResult.url + val hash = uploadResult.sha256 + if (url.isNullOrBlank() || hash.isNullOrBlank()) return error(UploadError.SERVER_DID_NOT_PROVIDE_URL) + val fileHeader = + FileHeader( + mimeType = uploadResult.type ?: localContentType, + hash = hash, + size = (uploadResult.size ?: localSize)?.toInt() ?: 0, + dim = uploadResult.dimension, + blurHash = uploadResult.blurHash, + ) + return finish(UploadOrchestrator.OrchestratorResult.ServerResult(fileHeader, url, uploadResult.magnet, hash, originalContentType, originalHash)) + } + private suspend fun verifyHeader( uploadResult: MediaUploadResult, localContentType: String?, @@ -536,6 +607,16 @@ class AndroidMediaUploader( uploadBlossomWithFallback(server, account) { baseUrl, auth -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, baseUrl, null, null, account, forcedSigner, context, auth) } + // Private to the workspace: never retried on, or mirrored to, a public server. + ServerType.BuzzWorkspace -> { + val clean = sanitizeForBuzzWorkspace(finalUri, compressed.contentType, context) + try { + uploadBlossom(clean?.uri ?: finalUri, compressed.contentType, clean?.size ?: compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context, SharedUploadAuth(), buzzWorkspace = true) + .also { if (it is UploadingState.Error) updateState(0.0, it) } + } finally { + clean?.file?.delete() + } + } } } finally { deleteTempUri(finalUri, uri) @@ -586,6 +667,10 @@ class AndroidMediaUploader( uploadBlossomWithFallback(server, account) { baseUrl, auth -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context, auth) } + + ServerType.BuzzWorkspace -> + uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context, SharedUploadAuth(), buzzWorkspace = true) + .also { if (it is UploadingState.Error) updateState(0.0, it) } } } finally { deleteTempUri(encrypted.uri, uri) @@ -593,3 +678,6 @@ class AndroidMediaUploader( } } } + +/** Lifetime of a Buzz workspace upload token; Buzz rejects one that outlives 60s from signing. */ +private const val BUZZ_UPLOAD_TOKEN_SECS = 55L diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index f8aaa70f92..61b3fc61de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -192,6 +192,9 @@ class BlossomUploader { requestBuilder .addHeader("Content-Length", length.toString()) + // The blob's hash (BUD-06). Servers that bind the body to the token's `x` tag before + // reading it — Buzz answers 401 without it — need it up front. + .addHeader(SHA256_HEADER, hash) .url(apiUrl) .put(requestBody) @@ -282,3 +285,5 @@ class BlossomUploader { private fun parseResults(body: String): BlossomUploadResult = JsonMapper.fromJson(body) } + +private const val SHA256_HEADER = "X-SHA-256" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploaderFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploaderFactory.kt index ffc280a8b1..b88fa27907 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploaderFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploaderFactory.kt @@ -74,6 +74,10 @@ object HlsBlobUploaderFactory { "NIP-95 storage stores each blob as an event and is not suitable for HLS renditions", ) } + + ServerType.BuzzWorkspace -> { + throw IllegalArgumentException("HLS publishing does not target a Buzz workspace's media server") + } } private fun blossomAdapter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt index 528deed877..b7c55b98ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.observeChatPreviewText import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary import com.vitorpamplona.quartz.nip29RelayGroups.GroupId @@ -259,10 +260,10 @@ private fun BuzzChannelPreviewLine( summary != null -> summary author != null -> { val authorName by observeUserName(author, accountViewModel) - val body = event.content.take(80) + val body = (lastNote?.let { observeChatPreviewText(it, 80) } ?: "") if (body.isBlank()) authorName else "$authorName: $body" } - else -> event.content.take(80) + else -> (lastNote?.let { observeChatPreviewText(it, 80) } ?: "") } } Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/ForumReplyNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/ForumReplyNewMessageViewModel.kt index d7845566f9..e7fa37627e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/ForumReplyNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/ForumReplyNewMessageViewModel.kt @@ -56,16 +56,16 @@ class ForumReplyNewMessageViewModel : ChannelNewMessageViewModel() { return null } - // Resolve `@`-mentions the same way the chat composer does: the tagger rewrites each name - // into a `nostr:` reference in the body AND collects the cited users (seeded with the reply - // target) so they land as `p` mention tags — without this, a named member was neither - // notified nor linked. + // Resolve `@`-mentions the same way the chat composer does: the tagger collects the cited + // users (seeded with the reply target) so they land as `p` mention tags — without this, a + // named member was neither notified nor linked. The body keeps Buzz's plain `@Name` form. val tagger = NewMessageTagger( message = message.text.toString(), pTags = listOfNotNull(replyTo.value?.author), eTags = listOfNotNull(replyTo.value), dao = accountViewModel, + userMentionsAsNames = true, ) tagger.run() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatDeliveryTicks.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatDeliveryTicks.kt index f2f0000f73..f3789c1ae9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatDeliveryTicks.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatDeliveryTicks.kt @@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.commons.resources.chat_delivery_details_title import com.vitorpamplona.amethyst.commons.resources.chat_delivery_failed import com.vitorpamplona.amethyst.commons.resources.chat_delivery_no_relay_info import com.vitorpamplona.amethyst.commons.resources.chat_delivery_pending +import com.vitorpamplona.amethyst.commons.resources.chat_delivery_rejected import com.vitorpamplona.amethyst.commons.resources.chat_delivery_sending import com.vitorpamplona.amethyst.commons.resources.close import com.vitorpamplona.amethyst.commons.ui.components.ClickableBox @@ -228,6 +229,7 @@ private fun ChatDeliveryDetailDialog( RelayDeliveryRow( relay = relay, accepted = relay in delivery.acceptedRelays || relay in seenOnRelays, + rejection = delivery.rejectedRelays[relay], ) } @@ -309,19 +311,34 @@ private fun RecipientDeliveryRow( private fun RelayDeliveryRow( relay: NormalizedRelayUrl, accepted: Boolean, + rejection: String? = null, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = relay.displayUrl(), - modifier = Modifier.weight(1f), - maxLines = 1, - ) + Column(modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = relay.displayUrl(), + modifier = Modifier.weight(1f), + maxLines = 1, + ) - DeliveryStatusTick(accepted) + if (!accepted && rejection != null) { + TickIcon(MaterialSymbols.Block, Res.string.chat_delivery_rejected, MaterialTheme.colorScheme.error) + } else { + DeliveryStatusTick(accepted) + } + } + // The relay's own words: the only clue to WHY a message will never arrive. + if (!accepted && rejection != null) { + Text( + text = rejection.ifBlank { stringRes(Res.string.chat_delivery_rejected) }, + color = MaterialTheme.colorScheme.error, + fontSize = Font12SP, + ) + } } } @@ -360,6 +377,13 @@ private fun RenderDeliveryTicks( else -> Unit } + // Every targeted relay refused it: it will never arrive. Not "pending" — the detail dialog + // behind this tick shows each relay's reason. + if (delivery?.isRejected == true) { + TickIcon(MaterialSymbols.Block, Res.string.chat_delivery_rejected, MaterialTheme.colorScheme.error) + return + } + if (delivery == null) { // Untracked (sent before a restart): the seen-on relay set is the only signal. if (seenSomewhere) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index c34785a2c8..edf2ff79a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -49,9 +49,6 @@ import com.vitorpamplona.amethyst.commons.chats.ui.ChatBubbleLayout import com.vitorpamplona.amethyst.commons.chats.ui.ChatGroupPosition import com.vitorpamplona.amethyst.commons.chats.ui.jumboEmojiCount import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.model.latestBuzzEdit -import com.vitorpamplona.amethyst.commons.model.latestConcordEdit -import com.vitorpamplona.amethyst.commons.model.latestMarmotEdit import com.vitorpamplona.amethyst.commons.model.navigation.routeFor import com.vitorpamplona.amethyst.commons.ui.components.LocalInlineQuoteRenderer import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav @@ -59,6 +56,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.commons.ui.note.WatchBlockAndReport import com.vitorpamplona.amethyst.commons.ui.note.WatchNoteEvent import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.DisplayZapSplits +import com.vitorpamplona.amethyst.commons.ui.note.types.observeChatMessageEdit import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderBuzzSystemMessage import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderChatClip import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderConcordTimerNotice @@ -66,6 +64,7 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.Re import com.vitorpamplona.amethyst.commons.ui.theme.ReactionRowZapraiser import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.amethyst.service.notifications.NotificationContent import com.vitorpamplona.amethyst.ui.note.RenderZapRaiser import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzActivityRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzDiff @@ -669,17 +668,22 @@ fun NoteRow( } /** - * The newest edit overlaying a chat message [note] (Concord kind-3302 or Buzz kind-40003), or null - * when unedited. A message is only ever one kind, so both resolve off the same [Note.edits] and one - * collector on the note's edits flow serves both — recomposing whenever an edit is added or removed. + * The text a one-line chat preview (room list, workspace rows) shows for [note]: the latest edit's + * text when there is one — the room shows the edit, so the list must too — with `nostr:` user + * references rewritten to `@Name`, as the bubble renders them. */ @Composable -fun observeChatEdit(note: Note): Note? { - val latest by - produceState(initialValue = null, note.idHex) { - note.flow().edits.stateFlow.collect { - value = note.latestConcordEdit() ?: note.latestBuzzEdit() ?: note.latestMarmotEdit() - } - } - return latest +fun observeChatPreviewText( + note: Note, + max: Int = 200, +): String { + val content = observeChatEdit(note)?.event?.content ?: note.event?.content ?: "" + return remember(content, max) { NotificationContent.resolveMentions(content, max).text } } + +/** + * The newest edit overlaying a chat message [note] (Concord, Buzz or Marmot), or null when unedited, + * recomposing whenever an edit is added or removed. + */ +@Composable +fun observeChatEdit(note: Note): Note? = observeChatMessageEdit(note) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt index f54529cd63..eb61bfed05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt @@ -57,9 +57,13 @@ import com.vitorpamplona.amethyst.commons.chats.publicChannels.concord.datasourc import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.commons.model.mediaServers.buzzWorkspaceServer import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.back @@ -87,6 +91,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadD import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.toConcordImeta import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.toPlainImetas +import com.vitorpamplona.quartz.buzz.threading.buzzThreadRootForReplyTo +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine @@ -153,13 +159,23 @@ fun MinichatScreen( val scope = rememberCoroutineScope() val context = LocalContext.current val canPost by remember { derivedStateOf { composer.text.isNotBlank() } } + // A thread in a Buzz workspace channel: its media must live on the workspace's own server + // (the relay refuses any other image host) and its typing signal is scoped to the thread. + val buzzGroup = + remember(rootNote) { + rootNote.inGatherers + ?.firstNotNullOfOrNull { it as? RelayGroupChannel } + ?.takeIf { BuzzRelayDialect.isBuzz(it.groupId.relayUrl) } + } val uploadState = - remember { + remember(buzzGroup) { ChatFileUploadState( accountViewModel.account.settings.defaultFileServer, accountViewModel.account.settings.stripLocationOnUpload, - ) + ).apply { lockServer(buzzGroup?.let { buzzWorkspaceServer(it.groupId.relayUrl.url) }) } } + // Client-side throttle for the Buzz kind-20002 typing heartbeat. + val lastTypingSecs = remember { longArrayOf(0L) } Scaffold( topBar = { @@ -249,7 +265,9 @@ fun MinichatScreen( } }, ) - accountViewModel.account.settings.changeDefaultFileServer(uploadState.selectedServer) + if (uploadState.lockedServer == null) { + accountViewModel.account.settings.changeDefaultFileServer(uploadState.selectedServer) + } accountViewModel.account.settings.changeStripLocationOnUpload(uploadState.stripMetadata) } }, @@ -262,6 +280,19 @@ fun MinichatScreen( Column(modifier = EditFieldModifier) { ThinPaddingTextField( state = composer, + onTextChanged = { + val group = buzzGroup + val now = TimeUtils.now() + if (group != null && composer.text.isNotEmpty() && now - lastTypingSecs[0] >= BuzzTypingState.TYPING_HEARTBEAT_SECS) { + lastTypingSecs[0] = now + val rootEvent = rootNote.event + accountViewModel.sendBuzzTyping( + group, + threadRootId = rootEvent?.tags?.buzzThreadRootForReplyTo(rootEvent.id) ?: rootNote.idHex, + replyToId = rootNote.idHex, + ) + } + }, onContentReceived = { uri, mimeType -> uploadState.load(persistentListOf(SelectedMedia(uri, mimeType))) // Same encryption choice the gallery button makes below. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index 324fd1b444..f3deacf181 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -120,6 +120,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzAddPeopleDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzImportRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzRelayImportViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzWorkspaceOverflowMenu +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.observeChatPreviewText import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_DM @@ -914,10 +915,10 @@ private fun BuzzDmPreviewLine( summary != null -> summary author != null -> { val authorName by observeUserName(author, accountViewModel) - val body = event.content.take(80) + val body = (lastNote?.let { observeChatPreviewText(it, 80) } ?: "") if (body.isBlank()) authorName else "$authorName: $body" } - else -> event.content.take(80) + else -> (lastNote?.let { observeChatPreviewText(it, 80) } ?: "") } Text( preview, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt index e22a0582ec..fb25f5ea3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt @@ -156,7 +156,17 @@ fun RelayGroupCreateScreen( accountViewModel = accountViewModel, nav = nav, nip29Support = nip29Support, - onSuccess = { nav.popUpTo(Route.RelayGroup(viewModel.groupId, relay.url), Route.RelayGroupCreate::class) }, + onSuccess = { + // A new forum opens on its thread list, where its posts live — the same screen the + // workspace list opens a forum on — not on the chat timeline a stream channel uses. + val destination = + if (viewModel.isForum) { + Route.RelayGroupThreads(viewModel.groupId, relay.url) + } else { + Route.RelayGroup(viewModel.groupId, relay.url) + } + nav.popUpTo(destination, Route.RelayGroupCreate::class) + }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index 15d335c1f0..6f4ac8d105 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -163,6 +163,10 @@ fun RelayGroupTopBar( var confirmDelete by remember { mutableStateOf(false) } val isBuzzRelay = remember(channel.groupId.relayUrl) { BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) } val huddleLive by observeBuzzHuddleLive(channel, enabled = isBuzzRelay && membership.isMember(), accountViewModel) + // Buzz names members only from profiles on its own relay; make sure mine is there. + LaunchedEffect(channel.groupId.relayUrl, isBuzzRelay) { + if (isBuzzRelay) accountViewModel.account.relayGroups.shareProfileWithBuzzWorkspace(channel.groupId.relayUrl) + } TopBarExtensibleWithBackButton( title = { @@ -269,7 +273,10 @@ fun RelayGroupTopBar( style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - } else if (!displayMembership.isMember() && channel.requiresMembershipToPost()) { + } else if (!displayMembership.isMember() && (channel.requiresMembershipToPost() || (isBuzzRelay && !isDm))) { + // A Buzz open channel takes posts from any workspace member, but channel membership + // is still what Buzz lists, counts and offers in its mention picker — its own client + // shows Join here too, so a reader can become a member. FilledTonalButton(onClick = { // Closed groups need an invite code; open groups join directly. if (channel.isClosed()) { @@ -418,14 +425,18 @@ fun RelayGroupTopBar( } }, ) - DropdownMenuItem( - text = { Text(stringRes(Res.string.leave), color = MaterialTheme.colorScheme.error) }, - onClick = { - menuOpen = false - accountViewModel.leaveRelayGroup(channel) - if (canPop) nav.popBack() - }, - ) + // A Buzz open channel lets a non-member read and post, but there is nothing to + // leave until they join (the Join button above stands in for it). + if (displayMembership.isMember() || !isBuzzRelay) { + DropdownMenuItem( + text = { Text(stringRes(Res.string.leave), color = MaterialTheme.colorScheme.error) }, + onClick = { + menuOpen = false + accountViewModel.leaveRelayGroup(channel) + if (canPop) nav.popBack() + }, + ) + } // Archive/Unarchive (kind-9002 `archived` tag) — a reversible hide-from-the-sidebar, // Buzz-only and admin-gated like Delete but NOT destructive, so no confirm dialog. // A DM is never archived (it has its own hide), so this is channels/forums only. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt index 539f57af85..67ebe3cce8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt @@ -135,7 +135,9 @@ fun ChannelFileUploadDialog( onceUploaded = onUpload, ) - accountViewModel.account.settings.changeDefaultFileServer(state.selectedServer) + if (state.lockedServer == null) { + accountViewModel.account.settings.changeDefaultFileServer(state.selectedServer) + } accountViewModel.account.settings.changeStripLocationOnUpload(state.stripMetadata) }, onCancel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 7acbf11897..920362c2e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel import com.vitorpamplona.amethyst.commons.model.latestBuzzEdit import com.vitorpamplona.amethyst.commons.model.location.LocationResult +import com.vitorpamplona.amethyst.commons.model.mediaServers.buzzWorkspaceServer import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership @@ -77,11 +78,13 @@ import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor +import com.vitorpamplona.quartz.buzz.media.BuzzImeta import com.vitorpamplona.quartz.buzz.stream.BuzzChatMessage import com.vitorpamplona.quartz.buzz.stream.BuzzEditTagOverlay import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent import com.vitorpamplona.quartz.buzz.stream.mentions import com.vitorpamplona.quartz.buzz.threading.buzzThreadRootForReplyTo +import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent @@ -419,6 +422,7 @@ open class ChannelNewMessageViewModel : // out so the relay accepts the mention. No-op unless this is a Buzz relay group the user // moderates; guarded so a failed add never blocks the message. channel?.let { autoInviteMentionedBuzzMembers(it, pendingBuzzInviteMentions) } + channel?.let { joinBuzzChannelBeforePosting(it) } // A geohash cell with no resolvable relays has nowhere to publish. Bail before cancel() clears // the composer, so the user keeps their text (and draft) to retry rather than losing it silently. @@ -506,6 +510,16 @@ open class ChannelNewMessageViewModel : } fun pickedMedia(list: ImmutableList) { + // A Buzz relay only accepts imeta URLs under its own /media/, so a Buzz channel's + // attachments go to the workspace's media server and nowhere else. + val channel = channel + uploadState?.lockServer( + if (channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) { + buzzWorkspaceServer(channel.groupId.relayUrl.url) + } else { + null + }, + ) uploadState?.load(list) } @@ -616,18 +630,38 @@ open class ChannelNewMessageViewModel : } } + /** + * Posting to a Buzz open channel joins it first. The relay takes a non-member's message there, + * but channel membership is what Buzz lists, counts and offers in its mention picker, and its + * own client requires joining before it lets anyone post — so a poster who never joined was + * invisible on the Buzz side. Best-effort: a failed join must never block the message. + */ + private suspend fun joinBuzzChannelBeforePosting(channel: Channel) { + if (channel !is RelayGroupChannel || !BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) return + if (channel.event?.isBuzzDm() == true) return + if (channel.membershipOf(accountViewModel.account.userProfile().pubkeyHex).isMember()) return + try { + accountViewModel.account.relayGroups.joinRelayGroup(channel) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("BuzzAutoJoin", "Failed to join ${channel.groupId.id} before posting", e) + } + } + // `protected open` so a specialized composer (e.g. the Buzz forum reply) can reuse this whole // rich EditFieldRow but swap only the event it builds for the composed text. protected open suspend fun createTemplate(): EventTemplate? { val channel = channel ?: return null val messageText = message.text.toString() + val isBuzzChannel = channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) val tagger = NewMessageTagger( message = messageText, pTags = listOfNotNull(replyTo.value?.author), eTags = listOfNotNull(replyTo.value), dao = accountViewModel, + userMentionsAsNames = isBuzzChannel, ) tagger.run() @@ -639,7 +673,11 @@ open class ChannelNewMessageViewModel : pendingBuzzInviteMentions = tagger.pTags?.filter { it != replyTo.value?.author }.orEmpty() val urls = findURLs(messageText) - val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) + val usedAttachments = + iMetaAttachments.filterIsIn(urls.toSet()).let { + // Buzz refuses the whole message over any imeta key it doesn't know (e.g. `ox`). + if (isBuzzChannel) BuzzImeta.sanitize(it) else it + } val emojis = accountViewModel.account.emoji.findEmojiTags(messageText) val channelRelays = channel.relays() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index ef4b75348f..4f77d73d5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -151,6 +151,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.commons.ui.theme.grayText import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.observeChatPreviewText import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.loadMarmotRelayIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupPreviewText @@ -697,8 +698,9 @@ private fun RelayGroupRoomCompose( val authorName by observeUserName(author, accountViewModel) // A Buzz timeline row (system line, huddle/job activity, diff) carries JSON/diff in its // content, so show its human-readable summary — the same text the in-chat row renders — - // rather than "author: {json}". Plain chat messages fall through to the usual framing. - buzzTimelinePreviewSummary(noteEvent, accountViewModel) ?: "$authorName: ${noteEvent.content.take(200)}" + // rather than "author: {json}". Plain chat messages fall through to the usual framing, + // showing the latest edit with mentions as names. + buzzTimelinePreviewSummary(noteEvent, accountViewModel) ?: "$authorName: ${observeChatPreviewText(lastMessage)}" } else { // Event-less placeholder row. Until the channel's `limit = 1` preview REQ settles we cannot // tell an empty channel from one whose newest message simply hasn't arrived, and claiming @@ -1009,7 +1011,7 @@ private fun RelayGroupServerRoomCompose( val authorName by observeUserName(author, accountViewModel) // Buzz timeline rows (system/huddle/job/diff) carry JSON/diff content — summarize them // like the in-chat row instead of printing raw payload; plain chat falls through. - buzzTimelinePreviewSummary(noteEvent, accountViewModel) ?: "$authorName: ${noteEvent.content.take(200)}" + buzzTimelinePreviewSummary(noteEvent, accountViewModel) ?: "$authorName: ${row.newestMessage?.let { observeChatPreviewText(it) } ?: ""}" } else { stringRes(Res.string.relay_group_no_messages_yet) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt index 488b3b7ea2..e4076dcac0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt @@ -230,18 +230,20 @@ private fun ImageVideoPostChat( ) } - SettingsRow(Res.string.file_server, Res.string.file_server_description) { - TextSpinner( - label = "", - placeholder = - fileServers - .firstOrNull { it.baseUrl == accountViewModel.account.settings.defaultFileServer.baseUrl } - ?.name - ?: fileServers.firstOrNull()?.name - ?: DEFAULT_MEDIA_SERVERS[0].name, - options = fileServerOptions, - onSelect = { fileUploadState.selectedServer = fileServers[it] }, - ) + if (fileUploadState.lockedServer == null) { + SettingsRow(Res.string.file_server, Res.string.file_server_description) { + TextSpinner( + label = "", + placeholder = + fileServers + .firstOrNull { it.baseUrl == accountViewModel.account.settings.defaultFileServer.baseUrl } + ?.name + ?: fileServers.firstOrNull()?.name + ?: DEFAULT_MEDIA_SERVERS[0].name, + options = fileServerOptions, + onSelect = { fileUploadState.selectedServer = fileServers[it] }, + ) + } } if (fileUploadState.multiOrchestrator?.hasCompressible() == true) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt index 2703b316e1..242217803c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt @@ -43,6 +43,20 @@ class ChatFileUploadState( val isUploadingFile: Boolean get() = mediaUploadTracker.isUploadingFile var selectedServer by mutableStateOf(defaultServer) + + /** + * A server this chat must upload to, e.g. a Buzz workspace's own media server, whose relay + * refuses any other image host. While set, the picker is hidden and the choice is not saved + * as the user's default. + */ + var lockedServer by mutableStateOf(null) + private set + + fun lockServer(server: ServerName?) { + lockedServer = server + selectedServer = server ?: defaultServer + } + var caption by mutableStateOf("") var contentWarning by mutableStateOf(false) @@ -73,7 +87,7 @@ class ChatFileUploadState( multiOrchestrator = null mediaUploadTracker.finishUpload() caption = "" - selectedServer = defaultServer + selectedServer = lockedServer ?: defaultServer encryptFiles = true } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt index dca2865779..c44fcaa61f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt @@ -185,6 +185,7 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.commons.service.upload.FileHeader import com.vitorpamplona.amethyst.commons.util.logTime import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode +import com.vitorpamplona.quartz.buzz.media.BuzzImeta import com.vitorpamplona.quartz.buzz.threading.buzzThread import com.vitorpamplona.quartz.buzz.threading.buzzThreadRootForReplyTo import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat @@ -2201,7 +2202,9 @@ class Account( // quick reply is notified (`p`) and their reference resolves. The reply-parent author is // already tagged by each builder below, so drop it from the body mentions to avoid a // duplicate `p`. - val tagger = NewMessageTagger(text, dao = LocalCache) + // Buzz shows a `nostr:` reference verbatim, so a Buzz thread names people as plain `@Name`. + val buzzGroup = gatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel }?.takeIf { BuzzRelayDialect.isBuzz(it.groupId.relayUrl) } + val tagger = NewMessageTagger(text, dao = LocalCache, userMentionsAsNames = buzzGroup != null) tagger.run() val mentions = tagger.pTags?.mapNotNull { it.pubkeyHex.takeIf { pk -> pk != rootEvent.pubKey } }.orEmpty() val finalText = appendMediaUrls(tagger.message, imetas) @@ -2237,7 +2240,7 @@ class Account( // their clients no longer thread on. Reading 40002 stays supported (see // [com.vitorpamplona.amethyst.commons.model.chats.isMinichatReply]). // - // Attached media rides as URLs appended to the content. + // Attached media rides as URLs appended to the content, plus Buzz-shaped `imeta`. val root = rootEvent.tags.buzzThreadRootForReplyTo(rootEvent.id) signer.sign( ChatEvent.build(finalText) { @@ -2246,6 +2249,8 @@ class Account( rootNote.author?.pubkeyHex?.let { pTag(PTag(it)) } pTags(mentions.map { PTag(it) }) previous(group.previousEventRefs(pubKey)) + // Buzz refuses the whole event over any imeta key it doesn't allow. + imetas(BuzzImeta.sanitize(imetas)) }, ) } else { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt index b9f51ea649..081d47de10 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt @@ -82,6 +82,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlin.uuid.ExperimentalUuidApi @@ -111,14 +112,42 @@ class AccountRelayGroupActions( account.follow(channel) } + // "relay|metadata event id" pairs already pushed this session, so each profile version goes to + // each workspace once. + private val profileSharedWith = MutableStateFlow>(emptySet()) + + /** + * Push my current kind-0 profile to the Buzz workspace at [relay]. Buzz reads member names and + * pictures only from its own relay, and its people search (mentions, Add people, new DM) is by + * name — without this, Buzz showed me as a bare npub and nobody could find or add me. Sends the + * already-signed event as-is (no new signature), once per profile version per workspace. + */ + fun shareProfileWithBuzzWorkspace(relay: NormalizedRelayUrl) { + if (!account.isWriteable()) return + val profile = account.userMetadata.getUserMetadataEvent() ?: return + val key = "${relay.url}|${profile.id}" + while (true) { + val current = profileSharedWith.value + if (key in current) return + if (profileSharedWith.compareAndSet(current, current + key)) break + } + account.client.publish(profile, setOf(relay)) + } + /** * Fire a Buzz kind-20002 typing heartbeat for [channel] to its host relay. Ephemeral * (never stored) and fire-and-forget — no delivery tracking, no local echo (we filter * our own typing in the UI). Throttled by the composer to [BuzzTypingState.TYPING_HEARTBEAT_SECS]. + * [threadRootId]/[replyToId] scope it to a thread, as Buzz's thread composer does. */ - suspend fun sendBuzzTyping(channel: RelayGroupChannel) { + suspend fun sendBuzzTyping( + channel: RelayGroupChannel, + threadRootId: HexKey? = null, + replyToId: HexKey? = null, + ) { if (!account.isWriteable()) return - val signed = account.signer.sign(TypingIndicatorEvent.build(channel.groupId.id)) + // In a thread, the `e` markers scope the signal to it: Buzz shows it in that thread's pane. + val signed = account.signer.sign(TypingIndicatorEvent.build(channel.groupId.id, threadRootId, replyToId)) account.client.publish(signed, setOf(channel.groupId.relayUrl)) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/composer/NewMessageTagger.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/composer/NewMessageTagger.kt index 368c2f6d1f..f97915f85b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/composer/NewMessageTagger.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/composer/NewMessageTagger.kt @@ -39,11 +39,17 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip19Bech32.toNpub import kotlinx.coroutines.CancellationException +/** + * @param userMentionsAsNames write a user mention as plain `@Name` text instead of a `nostr:` + * reference — the form Buzz clients send and render (the cited user still lands in [pTags], which is + * what notifies them). Buzz shows a `nostr:nprofile…` body verbatim. + */ class NewMessageTagger( var message: String, var pTags: List? = null, var eTags: List? = null, var dao: Dao, + val userMentionsAsNames: Boolean = false, ) { val directMentions = mutableSetOf() val directMentionsNotes = mutableSetOf() @@ -115,11 +121,11 @@ class NewMessageTagger( val results = parseDirtyWordForKey(word) when (val entity = results?.key?.entity) { is NPub -> { - getNostrAddress(dao.getOrCreateUser(entity.hex).toNProfile(), results.restOfWord) + userMention(dao.getOrCreateUser(entity.hex), results.restOfWord) } is NProfile -> { - getNostrAddress(dao.getOrCreateUser(entity.hex).toNProfile(), results.restOfWord) + userMention(dao.getOrCreateUser(entity.hex), results.restOfWord) } is com.vitorpamplona.quartz.nip19Bech32.entities.NNote -> { @@ -159,6 +165,19 @@ class NewMessageTagger( }.joinToString("\n") } + private fun userMention( + user: User, + restOfTheWord: String?, + ): String { + if (!userMentionsAsNames) return getNostrAddress(user.toNProfile(), restOfTheWord) + val name = "@" + user.toBestDisplayName() + return when { + restOfTheWord.isNullOrEmpty() -> name + restOfTheWord[0].isLetterOrDigit() -> "$name $restOfTheWord" + else -> name + restOfTheWord + } + } + fun getNostrAddress( bechAddress: String, restOfTheWord: String?, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/ServerName.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/ServerName.kt index 9fdfb3fafd..8c75a91a48 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/ServerName.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/ServerName.kt @@ -33,6 +33,22 @@ enum class ServerType { Blossom, NIP95, NIP96, + + /** + * A Buzz workspace's own Blossom endpoint (`https:///upload`). Blobs there are + * private to the workspace and the relay only accepts `imeta` URLs under its `/media/`, so an + * upload to it never falls back to, or mirrors onto, a public server. + */ + BuzzWorkspace, +} + +/** The media server of the Buzz workspace served at [relayUrl] (`wss://host` → `https://host`). */ +fun buzzWorkspaceServer(relayUrl: String): ServerName { + val host = + relayUrl + .substringAfter("://") + .substringBefore("/") + return ServerName(host, "https://$host", ServerType.BuzzWorkspace) } val DEFAULT_MEDIA_SERVERS: List = diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/CardFeedContentState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/CardFeedContentState.kt index 6569bf14c7..c16d050ac9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/CardFeedContentState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/CardFeedContentState.kt @@ -429,8 +429,16 @@ class CardFeedContentState( } } + /** + * Drops cards built from [deletedNotes] — e.g. a reaction its author took back, which used to + * linger on the notification card after it vanished from the message. A card can bundle many + * notes (all reactions to one post), so this rebuilds from the filter rather than editing cards. + */ fun deleteFromFeed(deletedNotes: Set) { - // TODO: Implement deletion of notes from the notification feed + val shown = lastNotes ?: return + if (deletedNotes.none { it in shown }) return + clear() + invalidateData() } fun trimToSize(maxItems: Int) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/chatDelivery/ChatDeliveryTracker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/chatDelivery/ChatDeliveryTracker.kt index d30a82fb24..db78798f8d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/chatDelivery/ChatDeliveryTracker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/chatDelivery/ChatDeliveryTracker.kt @@ -76,7 +76,19 @@ data class ChatDelivery( val acceptedRelays: Set = emptySet(), val recipients: List? = null, val sendState: ChatSendState = ChatSendState.SENT, + // Relays that answered `OK false`, with their reason. A later acceptance clears the entry. + val rejectedRelays: Map = emptyMap(), ) { + /** + * No relay took the message and every targeted relay refused it: it will never arrive, so the + * UI must say so (with the relay's reason) instead of showing it as still pending. + */ + val isRejected: Boolean + get() = + acceptedRelays.isEmpty() && + rejectedRelays.isNotEmpty() && + rejectedRelays.keys.containsAll(targetRelays) + /** The other participants' wraps (self-copy excluded); null for rooms. */ val otherRecipients: List? get() = recipients?.filterNot { it.isSelf } @@ -137,7 +149,10 @@ class ChatDeliveryTracker( private val retries = mutableMapOf Unit>() private val okCollector = - RelayInsertConfirmationCollector(client) { eventId, relay -> + RelayInsertConfirmationCollector( + client, + onRelayRejected = { eventId, relay, reason -> onRejected(eventId, relay.url, reason) }, + ) { eventId, relay -> onAccepted(eventId, relay.url) } @@ -297,7 +312,7 @@ class ChatDeliveryTracker( } } - private fun onAccepted( + internal fun onAccepted( eventId: HexKey, relay: NormalizedRelayUrl, ) { @@ -318,6 +333,7 @@ class ChatDeliveryTracker( // send path recorded. sendState = ChatSendState.SENT, acceptedRelays = delivery.acceptedRelays + relay, + rejectedRelays = delivery.rejectedRelays - relay, recipients = delivery.recipients?.map { if (it.recipient == recipient) { @@ -330,11 +346,28 @@ class ChatDeliveryTracker( } else { val flow = deliveries[eventId] ?: return val delivery = flow.value ?: return - flow.value = delivery.copy(sendState = ChatSendState.SENT, acceptedRelays = delivery.acceptedRelays + relay) + flow.value = delivery.copy(sendState = ChatSendState.SENT, acceptedRelays = delivery.acceptedRelays + relay, rejectedRelays = delivery.rejectedRelays - relay) } } } + internal fun onRejected( + eventId: HexKey, + relay: NormalizedRelayUrl, + reason: String, + ) { + if (eventId !in wrapIndex && eventId !in knownIds) return + + lock.withLock { + val noteId = wrapIndex[eventId]?.first ?: eventId + val flow = deliveries[noteId] ?: return + val delivery = flow.value ?: return + // A relay that already accepted the event (e.g. a resend after AUTH) keeps its tick. + if (relay in delivery.acceptedRelays) return + flow.value = delivery.copy(sendState = ChatSendState.SENT, rejectedRelays = delivery.rejectedRelays + (relay to reason)) + } + } + // Must run under [lock]. Also creates entries for ids queried by the UI // before their send registers (compose can win that race), so both paths // share the same flow instance. diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/chatDelivery/ChatDeliveryTrackerRejectionTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/chatDelivery/ChatDeliveryTrackerRejectionTest.kt new file mode 100644 index 0000000000..07909aae16 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/chatDelivery/ChatDeliveryTrackerRejectionTest.kt @@ -0,0 +1,81 @@ +/* + * 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.commons.relayClient.chatDelivery + +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** A relay's refusal (OK false) must surface with its reason, not as a message still pending. */ +class ChatDeliveryTrackerRejectionTest { + private val noteId = "a".repeat(64) + private val relayA = RelayUrlNormalizer.normalizeOrNull("wss://a.example.com")!! + private val relayB = RelayUrlNormalizer.normalizeOrNull("wss://b.example.com")!! + + @Test + fun everyTargetRefusing_isRejectedWithTheReason() { + val tracker = ChatDeliveryTracker(EmptyNostrClient()) + tracker.trackPublic(noteId, setOf(relayA)) + + tracker.onRejected(noteId, relayA, "invalid: imeta url must be a local /media/ path") + + val delivery = tracker.currentFor(noteId)!! + assertTrue(delivery.isRejected) + assertEquals("invalid: imeta url must be a local /media/ path", delivery.rejectedRelays[relayA]) + } + + @Test + fun oneRelayStillOpen_isNotRejectedYet() { + val tracker = ChatDeliveryTracker(EmptyNostrClient()) + tracker.trackPublic(noteId, setOf(relayA, relayB)) + + tracker.onRejected(noteId, relayA, "blocked: no") + + assertFalse(tracker.currentFor(noteId)!!.isRejected) + } + + @Test + fun aLaterAcceptanceClearsTheRefusal() { + val tracker = ChatDeliveryTracker(EmptyNostrClient()) + tracker.trackPublic(noteId, setOf(relayA)) + + tracker.onRejected(noteId, relayA, "rate-limited: slow down") + tracker.onAccepted(noteId, relayA) + + val delivery = tracker.currentFor(noteId)!! + assertFalse(delivery.isRejected) + assertTrue(delivery.rejectedRelays.isEmpty()) + } + + @Test + fun aRefusalAfterAcceptanceKeepsTheTick() { + val tracker = ChatDeliveryTracker(EmptyNostrClient()) + tracker.trackPublic(noteId, setOf(relayA)) + + tracker.onAccepted(noteId, relayA) + tracker.onRejected(noteId, relayA, "duplicate: already have it") + + assertTrue(tracker.currentFor(noteId)!!.rejectedRelays.isEmpty()) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/composer/NewMessageTaggerKeyParseTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/composer/NewMessageTaggerKeyParseTest.kt index 3cc2675f47..582c55e918 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/composer/NewMessageTaggerKeyParseTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/composer/NewMessageTaggerKeyParseTest.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache.getOrCreateAddr import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -184,4 +185,28 @@ class NewMessageTaggerKeyParseTest { ) assertEquals(",", result?.restOfWord) } + + @Test + fun userMentionsAsNamesWritesPlainAtNameAndStillTags() = + runBlocking { + val npub = "npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z" + val hex = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + val user = dao.getOrCreateUser(hex) + + val tagger = NewMessageTagger(message = "hi @$npub, there", dao = dao, userMentionsAsNames = true) + tagger.run() + + assertEquals("hi @${user.toBestDisplayName()}, there", tagger.message) + assertEquals(listOf(hex), tagger.pTags?.map { it.pubkeyHex }) + } + + @Test + fun defaultStillWritesNostrUri() = + runBlocking { + val npub = "npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z" + val tagger = NewMessageTagger(message = "hi @$npub", dao = dao) + tagger.run() + + assertTrue(tagger.message.startsWith("hi nostr:nprofile1")) + } } diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 9b2257719e..853b5b3d2f 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -5469,6 +5469,7 @@ No relay accepted the KeyPackage. Check your relay settings and try again. Sending\u2026 Could not be sent. Tap to try again. + The relay refused this message %1$d new message %1$d new messages diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Text.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Text.kt index 9b4cd4de87..ef8420054b 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Text.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Text.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -36,6 +37,9 @@ import androidx.compose.ui.text.style.TextOverflow import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.latestBuzzEdit +import com.vitorpamplona.amethyst.commons.model.latestConcordEdit +import com.vitorpamplona.amethyst.commons.model.latestMarmotEdit import com.vitorpamplona.amethyst.commons.model.navigation.routeFor import com.vitorpamplona.amethyst.commons.model.replyingDirectlyTo import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists @@ -55,6 +59,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.HalfVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent @@ -62,6 +67,7 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip14Subject.subject import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent enum class ReplyRenderType { FULL, @@ -158,6 +164,10 @@ fun RenderTextEvent( return } + // A chat message edited in place (Buzz kind-40003, Concord, Marmot) shows its newest edit here + // too — notification cards and quotes render it through this path, not the chat bubble. + val chatEdit = if (noteEvent is ChatEvent || noteEvent is StreamMessageV2Event) observeChatMessageEdit(note) else null + LoadDecryptedContent( note, accountViewModel, @@ -171,7 +181,7 @@ fun RenderTextEvent( ?.event ?.content ?: body } else { - body + chatEdit?.event?.content ?: body } val eventContent = remember(newBody) { displayedNoteText(note, newBody) } @@ -223,3 +233,18 @@ fun RenderTextEvent( } } } + +/** The newest in-place edit of a chat message (Concord, Buzz or Marmot), recomposing as edits land. */ +@Composable +fun observeChatMessageEdit(note: Note): Note? { + val latest by + produceState(initialValue = note.latestChatEdit(), note.idHex) { + note + .flow() + .edits.stateFlow + .collect { value = note.latestChatEdit() } + } + return latest +} + +private fun Note.latestChatEdit(): Note? = latestConcordEdit() ?: latestBuzzEdit() ?: latestMarmotEdit() diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountFeedContentStates.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountFeedContentStates.kt index 0ba87e47e5..5a7dc3fcf6 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountFeedContentStates.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountFeedContentStates.kt @@ -461,6 +461,8 @@ class AccountFeedContentStates( } fun deleteNotes(newNotes: Set) { + notifications.deleteFromFeed(newNotes) + notificationsFollowing.deleteFromFeed(newNotes) homeLive.deleteFromFeed(newNotes) homeNewThreads.deleteFromFeed(newNotes) homeReplies.deleteFromFeed(newNotes) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt index b53610ad2a..b218de03fc 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt @@ -764,10 +764,13 @@ class AccountViewModel( account.concord.sendConcordTyping(communityId, channelIdHex) } - fun sendBuzzTyping(channel: RelayGroupChannel) = - viewModelScope.launch(Dispatchers.IO) { - account.relayGroups.sendBuzzTyping(channel) - } + fun sendBuzzTyping( + channel: RelayGroupChannel, + threadRootId: HexKey? = null, + replyToId: HexKey? = null, + ) = viewModelScope.launch(Dispatchers.IO) { + account.relayGroups.sendBuzzTyping(channel, threadRootId, replyToId) + } @Immutable data class NoteComposeReportState( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImeta.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImeta.kt new file mode 100644 index 0000000000..9e62843c1d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImeta.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.media + +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag + +/** + * Buzz's relay validates every `imeta` tag on ingest (`crates/buzz-relay/src/handlers/imeta.rs`) + * and refuses the WHOLE event on the first key it does not know or a repeated singleton key. + * Generic NIP-92 attachments carry extras Buzz never accepts (`ox`, `content-warning`, …), so a + * message bound for a Buzz workspace keeps only the keys Buzz allows, each singleton once. + */ +object BuzzImeta { + /** `url` is the tag's anchor; it is not part of [IMetaTag.properties]. */ + val ALLOWED_KEYS = setOf("m", "x", "size", "dim", "blurhash", "alt", "thumb", "fallback", "duration", "bitrate", "image", "filename") + + /** Every allowed key but `fallback` may appear only once. */ + private const val REPEATABLE_KEY = "fallback" + + fun sanitize(tag: IMetaTag): IMetaTag = + IMetaTag( + tag.url, + tag.properties + .filterKeys { it in ALLOWED_KEYS } + .mapValues { (key, values) -> if (key == REPEATABLE_KEY) values else values.take(1) } + .filterValues { it.isNotEmpty() }, + ) + + fun sanitize(tags: List): List = tags.map(::sanitize) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizer.kt new file mode 100644 index 0000000000..3e6519ed2a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizer.kt @@ -0,0 +1,176 @@ +/* + * 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.buzz.media + +/** + * Makes an image acceptable to a Buzz workspace's media server, which refuses (HTTP 422) any blob + * carrying a metadata channel (`buzz-media/src/validation.rs`): in a JPEG every APP1–APP13/APP15 + * segment (EXIF, XMP, ICC…) and comment, plus any APP0/APP14 that isn't the canonical JFIF/Adobe + * header; in a PNG every text/EXIF/ICC chunk and every ancillary chunk that isn't a known rendering + * hint; and in both, any bytes after the end marker. Removing those leaves the pixels untouched. + * + * Other formats, and input that doesn't parse, come back unchanged — the server stays the judge. + */ +object BuzzMediaSanitizer { + fun sanitize( + bytes: ByteArray, + mimeType: String?, + ): ByteArray = + when (mimeType?.lowercase()) { + "image/jpeg", "image/jpg" -> stripJpeg(bytes) ?: bytes + "image/png" -> stripPng(bytes) ?: bytes + else -> bytes + } + + /** True when [mimeType] is one [sanitize] rewrites. */ + fun handles(mimeType: String?): Boolean = mimeType?.lowercase() in setOf("image/jpeg", "image/jpg", "image/png") + + private fun ByteArray.u8(i: Int) = this[i].toInt() and 0xff + + fun stripJpeg(b: ByteArray): ByteArray? { + if (b.size < 4 || b.u8(0) != 0xff || b.u8(1) != 0xd8) return null + val out = ByteBuilder(b.size) + out.write(b, 0, 2) + var i = 2 + while (i < b.size) { + if (b.u8(i) != 0xff) return null + var j = i + while (j < b.size && b.u8(j) == 0xff) j++ + if (j >= b.size) return null + val marker = b.u8(j) + when { + marker == 0xd9 -> { + out.write(byteArrayOf(0xff.toByte(), 0xd9.toByte()), 0, 2) + return out.toByteArray() + } + + marker == 0xda -> { + // Start of scan: entropy-coded data (and any later tables/scans) runs to the + // last end-of-image marker; whatever trails it is dropped. + val eoi = lastEoi(b) + if (eoi < j) return null + out.write(byteArrayOf(0xff.toByte()), 0, 1) + out.write(b, j, eoi + 2 - j) + return out.toByteArray() + } + + marker in 0xd0..0xd7 || marker == 0x01 -> { + out.write(byteArrayOf(0xff.toByte(), marker.toByte()), 0, 2) + i = j + 1 + } + + else -> { + if (j + 3 > b.size) return null + val len = (b.u8(j + 1) shl 8) or b.u8(j + 2) + if (len < 2) return null + val end = j + 1 + len + if (end > b.size) return null + if (keepJpegSegment(marker, b, j + 3, end)) { + out.write(byteArrayOf(0xff.toByte()), 0, 1) + out.write(b, j, end - j) + } + i = end + } + } + } + return null + } + + private fun keepJpegSegment( + marker: Int, + b: ByteArray, + payloadStart: Int, + end: Int, + ): Boolean { + val payloadLen = end - payloadStart + return when { + marker == 0xe0 -> + payloadLen >= 14 && + b.u8(payloadStart) == 'J'.code && + b.u8(payloadStart + 1) == 'F'.code && + b.u8(payloadStart + 2) == 'I'.code && + b.u8(payloadStart + 3) == 'F'.code && + b.u8(payloadStart + 4) == 0 && + payloadLen == 14 + 3 * b.u8(payloadStart + 12) * b.u8(payloadStart + 13) + + marker == 0xee -> + payloadLen == 12 && + b.decodeToString(payloadStart, payloadStart + 5) == "Adobe" + + marker in 0xe1..0xed || marker == 0xef || marker == 0xfe -> false + + else -> true + } + } + + private fun lastEoi(b: ByteArray): Int { + var k = b.size - 2 + while (k >= 0) { + if (b.u8(k) == 0xff && b.u8(k + 1) == 0xd9) return k + k-- + } + return -1 + } + + private val PNG_SIGNATURE = byteArrayOf(0x89.toByte(), 'P'.code.toByte(), 'N'.code.toByte(), 'G'.code.toByte(), 0x0d, 0x0a, 0x1a, 0x0a) + + // Ancillary chunks Buzz keeps: pure rendering hints. pHYs is excluded on purpose (identity channel). + private val PNG_RENDERING_CHUNKS = setOf("cHRM", "gAMA", "sBIT", "sRGB", "bKGD", "hIST", "tRNS", "sPLT", "acTL", "fcTL", "fdAT") + + fun stripPng(b: ByteArray): ByteArray? { + if (b.size < PNG_SIGNATURE.size) return null + for (k in PNG_SIGNATURE.indices) if (b[k] != PNG_SIGNATURE[k]) return null + val out = ByteBuilder(b.size) + out.write(b, 0, PNG_SIGNATURE.size) + var i = PNG_SIGNATURE.size + while (i + 12 <= b.size) { + val len = (b.u8(i) shl 24) or (b.u8(i + 1) shl 16) or (b.u8(i + 2) shl 8) or b.u8(i + 3) + if (len < 0) return null + val end = i + 12 + len + if (end > b.size) return null + val kind = b.decodeToString(i + 4, i + 8) + val ancillary = (b.u8(i + 4) and 0x20) != 0 + if (!ancillary || kind in PNG_RENDERING_CHUNKS) out.write(b, i, end - i) + i = end + if (kind == "IEND") return out.toByteArray() + } + return null + } + + private class ByteBuilder( + capacity: Int, + ) { + private var buf = ByteArray(maxOf(capacity, 16)) + private var size = 0 + + fun write( + src: ByteArray, + from: Int, + count: Int, + ) { + if (size + count > buf.size) buf = buf.copyOf(maxOf(buf.size * 2, size + count)) + src.copyInto(buf, size, from, from + count) + size += count + } + + fun toByteArray() = buf.copyOf(size) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt index 6b536de30b..3c81bf8b7f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt @@ -29,10 +29,13 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage import com.vitorpamplona.quartz.utils.Log /** - * Listens to INostrClient's onEvent messages for caching purposes. + * Listens to INostrClient's relay messages and reports `OK`s for published events: [onRelayReceived] for an acceptance and, when given, + * [onRelayRejected] for a definitive refusal with the relay's reason. An `auth-required:` refusal + * is NOT reported — the client authenticates and resends, so it is not the relay's final answer. */ class RelayInsertConfirmationCollector( val client: INostrClient, + val onRelayRejected: ((eventId: HexKey, relay: IRelayClient, reason: String) -> Unit)? = null, val onRelayReceived: (eventId: HexKey, relay: IRelayClient) -> Unit, ) { private val clientListener = @@ -42,8 +45,11 @@ class RelayInsertConfirmationCollector( msgStr: String, msg: Message, ) { - if (msg is OkMessage && msg.success) { + if (msg !is OkMessage) return + if (msg.success) { onRelayReceived(msg.eventId, relay) + } else if (!msg.message.startsWith(AUTH_REQUIRED_PREFIX)) { + onRelayRejected?.invoke(msg.eventId, relay, msg.message) } } } @@ -59,3 +65,5 @@ class RelayInsertConfirmationCollector( client.removeConnectionListener(clientListener) } } + +private const val AUTH_REQUIRED_PREFIX = "auth-required:" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt index 12e1fb959e..1adc50e470 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd @@ -311,6 +312,29 @@ class PoolRequests( } } + is NoticeMessage -> { + // A relay that refuses an over-cap REQ by NOTICE (Buzz: "REQ contains 12 filters, + // maximum is 10") never says WHICH sub it dropped, and that sub would wait for an + // EOSE forever. Once the cap is known, every sub whose last REQ on this relay went + // over it is treated as closed and re-decided, which now narrows it under the cap. + val cap = relayRefusals.onNotice(relay.url, msg.message) ?: return + relayState.forEach { subId, state -> + val cmd = + state.withLock(relay.url) { + val sent = state.currentFilters(relay.url) + if (sent != null && sent.size > cap) { + state.onClosed(relay.url) + decideCommandLocked(state, subId, relay.url) + } else { + null + } + } + if (cmd != null && cmd !is CloseCmd) { + relay.sendOrConnectAndSync(cmd) + } + } + } + is ClosedMessage -> { // Relay-wide capability tracking is independent of any single sub's lock. Run // it first so decideCommandLocked below already sees the block; if it newly diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusals.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusals.kt index 3c8d87bca4..4ba20dd7a9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusals.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusals.kt @@ -214,6 +214,23 @@ class RelayReqRefusals( fun disallowedKinds(relay: NormalizedRelayUrl): Set = disallowedKinds[relay] ?: emptySet() + /** The most filters [relay] has said it accepts in one REQ, if it has said so. */ + fun maxFilters(relay: NormalizedRelayUrl): Int? = maxFilters[relay] + + /** + * Learn a filter cap from a NOTICE. Some relays (Buzz) refuse an over-cap REQ with a NOTICE + * instead of a CLOSED, so it names no subscription. Returns the cap when this notice taught + * one, so the caller can re-send whichever REQs went over it. + */ + fun onNotice( + relay: NormalizedRelayUrl, + message: String, + ): Int? { + parseMaxFilters(message) ?: return null + learnMaxFilters(relay, message) + return maxFilters[relay] + } + private fun classify(reason: String): Policy? { val t = reason.lowercase() if (SEARCH_REQUIRED_MARKERS.any { it in t }) return Policy.SEARCH_ONLY @@ -229,10 +246,17 @@ class RelayReqRefusals( // "invalid number of filters: 4" (strfry policy) — the relay refused N, so it takes fewer. private val INVALID_FILTER_COUNT = Regex("""invalid number of filters:?\s*([0-9]+)""") + // "REQ contains 12 filters, maximum is 10" (Buzz, sent as a NOTICE) — names the cap itself. + private val FILTER_COUNT_WITH_MAXIMUM = Regex("""([0-9]+) filters?,?\s*(?:the )?max(?:imum)?(?: is|:)?\s*([0-9]+)""") + fun parseMaxFilters(reason: String): Int? { + val lower = reason.lowercase() + FILTER_COUNT_WITH_MAXIMUM.find(lower)?.let { match -> + return match.groupValues[2].toIntOrNull()?.takeIf { it >= 1 } + } val refusedCount = INVALID_FILTER_COUNT - .find(reason.lowercase()) + .find(lower) ?.groupValues ?.get(1) ?.toIntOrNull() ?: return null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/NostrUris.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/NostrUris.kt index dafd8063e4..6fc4170d86 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/NostrUris.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/NostrUris.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip10Notes.content import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser -fun findNostrUris(content: String) = Nip19Parser.parseAll(content) +/** The NIP-19 references [content] cites — not ones inside a link, like a Blossom `npub1…` subdomain. */ +fun findNostrUris(content: String) = Nip19Parser.parseAllStandalone(content) fun findNostrEventUris(content: String) = Nip19Parser.parseAllEvents(content) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt index c7e83091ac..04227c65e7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt @@ -242,6 +242,7 @@ object Nip19Parser { content: String, fixed58Prefixes: Array, variablePrefixes: Array, + standaloneOnly: Boolean = false, action: (type: String, key: String, additionalChars: String) -> Unit, ) { var i = 0 @@ -265,7 +266,7 @@ object Nip19Parser { else -> if (nextLower < nextUpper) nextLower else nextUpper } if (i >= len) return - if (isCandidateAt(content, i)) { + if (isCandidateAt(content, i) && !(standaloneOnly && isEmbeddedAt(content, i))) { var end = -1 for (prefix in fixed58Prefixes) { @@ -312,6 +313,23 @@ object Nip19Parser { } } + /** + * True when the entity starting at [at] is glued to the token before it — an `npub1…` that is a + * URL's subdomain (`https://npub1….blossom.band/…`) or part of a path or word — rather than a + * reference on its own. `nostr:` and `@` prefixes are part of a reference, so they are skipped. + */ + private fun isEmbeddedAt( + content: String, + at: Int, + ): Boolean { + var j = at + if (j > 0 && content[j - 1] == '@') j-- + if (j >= 6 && content.regionMatches(j - 6, "nostr:", 0, 6, ignoreCase = true)) j -= 6 + if (j == 0) return false + val c = content[j - 1] + return c.isLetterOrDigit() || c == '/' || c == '.' || c == '-' || c == '_' || c == '=' || c == '?' || c == '&' || c == '#' + } + private fun allBech32( content: String, from: Int, @@ -345,6 +363,19 @@ object Nip19Parser { return returningList } + /** + * Like [parseAll], but only references that stand on their own: an entity glued to the text + * before it (an `npub1…` subdomain of a Blossom URL, say) is not something the author cited, so + * tagging it would `p`-tag (and notify) whoever's key happens to be in a link. + */ + fun parseAllStandalone(content: String): List { + val returningList = mutableListOf() + forEachNip19Match(content, FIXED_58_PREFIXES, VARIABLE_PREFIXES, standaloneOnly = true) { type, key, additionalChars -> + parseComponents(type, key, additionalChars)?.entity?.let { returningList.add(it) } + } + return returningList + } + /** Same scan as [parseAll], restricted to the event-ish entities. */ fun parseAllEvents(content: String): List { val returningList = mutableListOf() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt index e48f60a840..902fedd5a2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt @@ -96,6 +96,10 @@ class BlossomAuthorizationEvent( createdAt: Long = TimeUtils.now(), ) = createAuth("delete", hash, null, alt, signer, servers, createdAt) + /** + * [expiration] defaults to an hour ahead. Servers that bound the token's lifetime (Buzz + * rejects `expiration > created_at + 60` in its strict mode) need a shorter one. + */ suspend fun createUploadAuth( hash: HexKey, size: Long, @@ -103,7 +107,8 @@ class BlossomAuthorizationEvent( signer: NostrSigner, servers: List = emptyList(), createdAt: Long = TimeUtils.now(), - ) = createAuth("upload", hash, size, alt, signer, servers, createdAt) + expiration: Long = TimeUtils.oneHourAhead(), + ) = createAuth("upload", hash, size, alt, signer, servers, createdAt, expiration) /** * BUD-05 media-optimization auth (`t=media`). The [hash] is the sha256 of @@ -127,6 +132,7 @@ class BlossomAuthorizationEvent( signer: NostrSigner, servers: List = emptyList(), createdAt: Long = TimeUtils.now(), + expiration: Long = TimeUtils.oneHourAhead(), ): BlossomAuthorizationEvent { // BUD-11 `server` tags scope the token to specific domains so an upload // or delete token can't be replayed against another server. The value @@ -141,7 +147,7 @@ class BlossomAuthorizationEvent( val tags = listOfNotNull( arrayOf("t", type), - arrayOf("expiration", TimeUtils.oneHourAhead().toString()), + arrayOf("expiration", expiration.toString()), fileSize?.let { arrayOf("size", it.toString()) }, hash?.let { arrayOf("x", it) }, ) + serverTags diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImetaTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImetaTest.kt new file mode 100644 index 0000000000..6c231eb58d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImetaTest.kt @@ -0,0 +1,67 @@ +/* + * 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.buzz.media + +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import kotlin.test.Test +import kotlin.test.assertEquals + +class BuzzImetaTest { + @Test + fun dropsKeysBuzzRejectsAndKeepsTheRest() { + val tag = + IMetaTag( + "https://ws.example/media/abc.jpg", + mapOf( + "x" to listOf("abc"), + "ox" to listOf("def"), + "m" to listOf("image/jpeg"), + "size" to listOf("2029"), + "dim" to listOf("96x96"), + "blurhash" to listOf("U1Ku"), + "alt" to listOf("caption"), + "content-warning" to listOf("nsfw"), + ), + ) + + val clean = BuzzImeta.sanitize(tag) + + assertEquals("https://ws.example/media/abc.jpg", clean.url) + assertEquals(setOf("x", "m", "size", "dim", "blurhash", "alt"), clean.properties.keys) + } + + @Test + fun keepsOneValueForSingletonsButEveryFallback() { + val tag = + IMetaTag( + "https://ws.example/media/abc.jpg", + mapOf( + "alt" to listOf("one", "two"), + "fallback" to listOf("https://a/1", "https://b/1"), + ), + ) + + val clean = BuzzImeta.sanitize(tag) + + assertEquals(listOf("one"), clean.properties["alt"]) + assertEquals(listOf("https://a/1", "https://b/1"), clean.properties["fallback"]) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizerTest.kt new file mode 100644 index 0000000000..74ffa32777 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizerTest.kt @@ -0,0 +1,94 @@ +/* + * 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.buzz.media + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +class BuzzMediaSanitizerTest { + private fun bytes(vararg v: Int) = ByteArray(v.size) { v[it].toByte() } + + private fun seg( + marker: Int, + payload: ByteArray, + ): ByteArray { + val len = payload.size + 2 + return bytes(0xff, marker, len shr 8, len and 0xff) + payload + } + + private val jfif = seg(0xe0, "JFIF".encodeToByteArray() + bytes(0, 1, 1, 0, 0, 1, 0, 1, 0, 0)) + private val exif = seg(0xe1, "Exif".encodeToByteArray() + bytes(0, 0, 1, 2, 3, 4)) + private val icc = seg(0xe2, "ICC_PROFILE".encodeToByteArray() + bytes(0, 1, 1)) + private val comment = seg(0xfe, "made with a phone".encodeToByteArray()) + private val dqt = seg(0xdb, ByteArray(65) { 1 }) + private val sosAndData = seg(0xda, bytes(1, 1, 0, 0, 63, 0)) + bytes(0x12, 0x34, 0xff, 0x00, 0x56) + private val eoi = bytes(0xff, 0xd9) + + @Test + fun jpegLosesExifIccCommentsAndTrailingBytes() { + val input = bytes(0xff, 0xd8) + jfif + exif + icc + comment + dqt + sosAndData + eoi + "trailer".encodeToByteArray() + + val clean = BuzzMediaSanitizer.sanitize(input, "image/jpeg") + + assertContentEquals(bytes(0xff, 0xd8) + jfif + dqt + sosAndData + eoi, clean) + } + + @Test + fun nonCanonicalApp0IsDropped() { + val jfxx = seg(0xe0, "JFXX".encodeToByteArray() + bytes(0, 0x10, 1, 2, 3)) + val input = bytes(0xff, 0xd8) + jfxx + dqt + sosAndData + eoi + + assertContentEquals(bytes(0xff, 0xd8) + dqt + sosAndData + eoi, BuzzMediaSanitizer.sanitize(input, "image/jpeg")) + } + + private fun chunk( + kind: String, + data: ByteArray, + ) = bytes(data.size shr 24, data.size shr 16 and 0xff, data.size shr 8 and 0xff, data.size and 0xff) + kind.encodeToByteArray() + data + bytes(0, 0, 0, 0) + + private val pngSig = bytes(0x89, 'P'.code, 'N'.code, 'G'.code, 0x0d, 0x0a, 0x1a, 0x0a) + + @Test + fun pngKeepsRenderingChunksAndDropsMetadata() { + val ihdr = chunk("IHDR", ByteArray(13)) + val srgb = chunk("sRGB", bytes(0)) + val text = chunk("tEXt", "Comment\u0000hello".encodeToByteArray()) + val phys = chunk("pHYs", ByteArray(9)) + val iccp = chunk("iCCP", ByteArray(4)) + val idat = chunk("IDAT", bytes(1, 2, 3)) + val iend = chunk("IEND", ByteArray(0)) + val input = pngSig + ihdr + srgb + text + phys + iccp + idat + iend + bytes(9, 9) + + val clean = BuzzMediaSanitizer.sanitize(input, "image/png") + + assertContentEquals(pngSig + ihdr + srgb + idat + iend, clean) + } + + @Test + fun otherFormatsAndBrokenInputPassThrough() { + val webp = "RIFF....WEBP".encodeToByteArray() + assertContentEquals(webp, BuzzMediaSanitizer.sanitize(webp, "image/webp")) + val notJpeg = bytes(1, 2, 3, 4) + assertContentEquals(notJpeg, BuzzMediaSanitizer.sanitize(notJpeg, "image/jpeg")) + assertEquals(true, BuzzMediaSanitizer.handles("IMAGE/JPEG")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsFilterCapTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsFilterCapTest.kt index bc15867fcf..e5e0427ecb 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsFilterCapTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsFilterCapTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.pool import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -81,8 +82,44 @@ class PoolRequestsFilterCapTest { fun learnsTheCapFromTheRefusal() { assertEquals(3, RelayReqRefusals.parseMaxFilters("ERROR: bad req: filter validation failed: invalid number of filters: 4")) assertEquals(null, RelayReqRefusals.parseMaxFilters("ERROR: bad req: filter validation failed: kind not allowed: 21059")) + assertEquals(10, RelayReqRefusals.parseMaxFilters("invalid message: Invalid message format: REQ contains 12 filters, maximum is 10")) } + /** + * Buzz refuses an over-cap REQ with a NOTICE, which names no subscription — the sub would wait + * for an EOSE forever. The cap it names must be learned and the over-cap sub sent again under it. + */ + @Test + fun aBuzzNoticeResendsTheOverCapSubUnderTheCap() = + kotlinx.coroutines.test.runTest { + val buzz = NormalizedRelayUrl("wss://ws.communities.buzz.xyz/") + val pool = PoolRequests() + pool.addOrUpdate("big", mapOf(buzz to groupFilters(12)), null) + pool.addOrUpdate("small", mapOf(buzz to groupFilters(2)), null) + pool.onConnecting(buzz) + pool.syncState(buzz) { } + + val client = RecordingRelayClient(buzz) + pool.onIncomingMessage(client, NoticeMessage("invalid message: Invalid message format: REQ contains 12 filters, maximum is 10")) + + val resent = client.sent.filterIsInstance() + assertEquals(listOf("big"), resent.map { it.subId }, "only the sub that went over the cap is re-sent") + assertTrue(resent.single().filters.size <= 10) + } + + @Test + fun anUnrelatedNoticeChangesNothing() = + kotlinx.coroutines.test.runTest { + val pool = PoolRequests() + pool.addOrUpdate("big", mapOf(relay to groupFilters(12)), null) + sync(pool, relay) + + val client = RecordingRelayClient(relay) + pool.onIncomingMessage(client, NoticeMessage("rate limited, slow down")) + + assertTrue(client.sent.isEmpty()) + } + @Test fun perGroupFiltersAreMergedUnderTheCapAndSentAgainAtOnce() = kotlinx.coroutines.test.runTest { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/content/FindNostrUrisTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/content/FindNostrUrisTest.kt new file mode 100644 index 0000000000..ee392fa8bd --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/content/FindNostrUrisTest.kt @@ -0,0 +1,49 @@ +/* + * 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.content + +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** An npub that is part of a link (a Blossom subdomain) is not a citation and must not be tagged. */ +class FindNostrUrisTest { + private val npub = "npub1rfahrcqpwh0rp7f2jcq8gsh4y04wawpthvw3fhmarvt8hsf80mvsr0tlnr" + + @Test + fun npubInsideABlossomHostnameIsNotCited() { + assertTrue(findNostrUris("https://$npub.blossom.band/e069d411.jpg").isEmpty()) + } + + @Test + fun npubInAPathIsNotCited() { + assertTrue(findNostrUris("see https://example.com/p/$npub").isEmpty()) + } + + @Test + fun standaloneReferencesAreStillCited() { + assertEquals(1, findNostrUris("hi nostr:$npub").filterIsInstance().size) + assertEquals(1, findNostrUris("hi @$npub, there").filterIsInstance().size) + assertEquals(1, findNostrUris("$npub at the start").filterIsInstance().size) + assertEquals(1, findNostrUris("($npub)").filterIsInstance().size) + } +} From 864fcae134f3597bb202ea1ca1a44004da3fa62d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 1 Oct 2026 08:46:03 -0400 Subject: [PATCH 2/3] fix(buzz): fixes from the Buzz-side retest - The profile now actually reaches the workspace: Buzz refuses an old kind-0 ("event timestamp too far from server time"), so sign a fresh copy with the same content for that relay only, once per profile version. It follows the profile note, and the top bar's Buzz check is reactive (a cold start opened the channel before the relay was marked Buzz, so the push never ran and Join stayed hidden). - Attachments are written as `![image](url)` in Buzz messages: Buzz only draws media linked that way, and showed a bare URL as a link. - A Buzz @mention notifies even from someone I don't follow (workspaces are membership-gated, like Concord). - Forum comments count as replies of their post, so the forum list shows the right reply count; forums opened from Messages land on their threads. - Kind-9 notification cards and quotes show the latest edit. Co-Authored-By: Claude Opus 5.5 --- .../relayGroup/RelayGroupTopBar.kt | 14 +++++++-- .../send/ChannelNewMessageViewModel.kt | 3 +- .../chats/rooms/ChatroomHeaderCompose.kt | 10 ++++++- .../amethyst/commons/model/Account.kt | 3 +- .../commons/model/AccountRelayGroupActions.kt | 29 ++++++++++++++----- .../commons/model/cache/EventCache.kt | 7 +++++ .../dal/NotificationFeedFilter.kt | 27 ++++++++++++++++- .../amethyst/commons/ui/note/types/Chat.kt | 7 +++-- .../quartz/buzz/media/BuzzImeta.kt | 20 +++++++++++++ .../quartz/buzz/media/BuzzImetaTest.kt | 19 ++++++++++++ 10 files changed, 123 insertions(+), 16 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index 6f4ac8d105..8422bc9b5f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -161,11 +161,19 @@ fun RelayGroupTopBar( var showInvite by remember { mutableStateOf(false) } var showJoinCode by remember { mutableStateOf(false) } var confirmDelete by remember { mutableStateOf(false) } - val isBuzzRelay = remember(channel.groupId.relayUrl) { BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) } + // Reactive: a relay is only marked Buzz once its first verified Buzz event lands, which can be + // after this screen opens (e.g. straight from Messages on a cold start). A remembered snapshot + // stayed false for the screen's life, hiding Join and never sharing the profile. + val buzzRelays by BuzzRelayDialect.flow.collectAsStateWithLifecycle() + val isBuzzRelay = channel.groupId.relayUrl in buzzRelays val huddleLive by observeBuzzHuddleLive(channel, enabled = isBuzzRelay && membership.isMember(), accountViewModel) - // Buzz names members only from profiles on its own relay; make sure mine is there. + // Buzz names members only from profiles on its own relay; make sure mine is there. Follows the + // profile note so a profile that loads after the screen opens (or is edited later) still goes out. LaunchedEffect(channel.groupId.relayUrl, isBuzzRelay) { - if (isBuzzRelay) accountViewModel.account.relayGroups.shareProfileWithBuzzWorkspace(channel.groupId.relayUrl) + if (!isBuzzRelay) return@LaunchedEffect + accountViewModel.account.userMetadata + .getUserMetadataFlow() + .collect { accountViewModel.account.relayGroups.shareProfileWithBuzzWorkspace(channel.groupId.relayUrl) } } TopBarExtensibleWithBackButton( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 920362c2e6..cba4b4a5ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -901,7 +901,8 @@ open class ChannelNewMessageViewModel : val threadRoot = parent?.let { it.event?.tags?.buzzThreadRootForReplyTo(it.idHex) ?: it.idHex } BuzzChatMessage.build( channelId = channel.groupId.id, - content = tagger.message, + // Buzz draws an attachment only where the body links it as `![image](url)`. + content = BuzzImeta.markdownMediaBody(tagger.message, usedAttachments), threadRoot = threadRoot, replyTo = parent?.idHex, // `p` mentions for everyone cited in the body (plus the reply target, which the diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 4f77d73d5d..572158dea8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -161,6 +161,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberM import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.reportWarningContentDescription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent +import com.vitorpamplona.quartz.buzz.workspace.isBuzzForum import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaTag import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent @@ -812,7 +813,14 @@ fun RelayGroupRow( accountViewModel.settings.autoPlayVideosFlow .collectAsStateWithLifecycle() .value, - onClick = { nav.nav(Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url)) }, + onClick = { + // A Buzz forum's posts are its threads; open it there, as the workspace list does. + if (channel.event?.isBuzzForum() == true) { + nav.nav(Route.RelayGroupThreads(channel.groupId.id, channel.groupId.relayUrl.url)) + } else { + nav.nav(Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url)) + } + }, onLongClick = { menuOpen = true }, ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt index c44fcaa61f..886cbeea46 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt @@ -2243,7 +2243,8 @@ class Account( // Attached media rides as URLs appended to the content, plus Buzz-shaped `imeta`. val root = rootEvent.tags.buzzThreadRootForReplyTo(rootEvent.id) signer.sign( - ChatEvent.build(finalText) { + // Buzz draws an attachment only where the body links it as `![image](url)`. + ChatEvent.build(BuzzImeta.markdownMediaBody(finalText, imetas)) { hTag(group.groupId.id) buzzThread(root, rootEvent.id) rootNote.author?.pubkeyHex?.let { pTag(PTag(it)) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt index 081d47de10..8a944a14d6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt @@ -52,6 +52,7 @@ import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks @@ -112,26 +113,37 @@ class AccountRelayGroupActions( account.follow(channel) } - // "relay|metadata event id" pairs already pushed this session, so each profile version goes to - // each workspace once. + // "relay|profile content" pairs already pushed this session, so each profile version goes to each + // workspace once. Keyed on content, not event id: the copy we sign comes back from the relay as a + // newer kind-0 with the same content, and must not trigger another signature. private val profileSharedWith = MutableStateFlow>(emptySet()) /** * Push my current kind-0 profile to the Buzz workspace at [relay]. Buzz reads member names and * pictures only from its own relay, and its people search (mentions, Add people, new DM) is by - * name — without this, Buzz showed me as a bare npub and nobody could find or add me. Sends the - * already-signed event as-is (no new signature), once per profile version per workspace. + * name — without this, Buzz showed me as a bare npub and nobody could find or add me. + * + * Buzz refuses an event whose timestamp is far from its clock ("event timestamp too far from + * server time"), so the already-signed profile — usually days old — can't be relayed as-is. Like + * Buzz's own client, this signs a fresh copy with the same content and tags, and sends it ONLY to + * that workspace, once per profile version. */ - fun shareProfileWithBuzzWorkspace(relay: NormalizedRelayUrl) { + suspend fun shareProfileWithBuzzWorkspace(relay: NormalizedRelayUrl) { if (!account.isWriteable()) return val profile = account.userMetadata.getUserMetadataEvent() ?: return - val key = "${relay.url}|${profile.id}" + val key = "${relay.url}|${profile.content.hashCode()}|${profile.tags.contentDeepHashCode()}" while (true) { val current = profileSharedWith.value if (key in current) return if (profileSharedWith.compareAndSet(current, current + key)) break } - account.client.publish(profile, setOf(relay)) + val fresh = + if (TimeUtils.now() - profile.createdAt < BUZZ_FRESH_PROFILE_SECS) { + profile + } else { + account.signer.sign(TimeUtils.now(), MetadataEvent.KIND, profile.tags, profile.content) + } + account.client.publish(fresh, setOf(relay)) } /** @@ -692,3 +704,6 @@ internal fun buzzRoleFor(roles: List): String { else -> BUZZ_ROLE_MEMBER } } + +/** A profile signed this recently is within Buzz's timestamp window and can be relayed as-is. */ +private const val BUZZ_FRESH_PROFILE_SECS = 60L diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index eab486d0d5..76a2b68b82 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -1382,6 +1382,13 @@ open class EventCache : listOfNotNull(event.parentPullRequestId()?.let { checkGetOrCreateNote(it) }) } + is ForumCommentEvent -> { + // A Buzz forum comment counts as a reply of its thread's root post (and of the comment + // it answers, when nested), so the forum list's reply count sees it. A direct reply + // carries only the `reply` marker, which then IS the root. + listOfNotNull(event.threadRoot(), event.replyTo()).distinct().map { getOrCreateNote(it) } + } + is GitStatusEvent -> { // A status event roots itself at a patch/PR/issue via a // marked-`root` `e` tag; link only that so the transition diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/dal/NotificationFeedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/dal/NotificationFeedFilter.kt index a6c26603e3..5b30df6d39 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/dal/NotificationFeedFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/dal/NotificationFeedFilter.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.feeds.sortedByDefaultFeedOrder import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel @@ -406,6 +407,7 @@ class NotificationFeedFilter( override fun feed(): List { val filterParams = buildFilterParams(account) + com.vitorpamplona.quartz.utils.Log val notifications = LocalCache.notes.filterIntoSet { _, note -> @@ -435,6 +437,7 @@ class NotificationFeedFilter( private fun innerApplyFilter(collection: Collection): Set { val filterParams = buildFilterParams(account) + com.vitorpamplona.quartz.utils.Log return collection.filterTo(HashSet()) { acceptableEvent(it, filterParams) } } @@ -474,6 +477,25 @@ class NotificationFeedFilter( ?.pubkeyHex == me } + /** + * A Buzz workspace message that `p`-tags me — Buzz only `p`-tags @mentions. A workspace is a + * closed, membership-gated space, so a coworker's @mention is relevant whether or not I follow + * them (like Concord), and it should not depend on the per-kind reply heuristics either: Buzz + * mentions name no event of mine. + */ + private fun isBuzzMentionOfMe( + note: Note, + me: HexKey, + ): Boolean { + val event = note.event + if (event !is ChatEvent && event !is StreamMessageV2Event) return false + if (!event.isTaggedUser(me)) return false + val group = LocalCache.getRelayGroupChannelForContent(note) + com.vitorpamplona.quartz.utils.Log + if (group == null) return false + return BuzzRelayDialect.isBuzz(group.groupId.relayUrl) + } + /** * A Buzz chat **thread reply** into one of my messages, when the reply carries no `p` tag. * @@ -509,6 +531,9 @@ class NotificationFeedFilter( filterParams: FilterByListParams, ): Boolean { val loggedInUserHex = account.userProfile().pubkeyHex + if (it.event?.content?.startsWith("BZ-6") == true) { + com.vitorpamplona.quartz.utils.Log + } // When the user opts out of seeing Messages on the Notification tab, drop // direct/group message events (DMs and Marmot group chats) entirely. @@ -666,7 +691,7 @@ class NotificationFeedFilter( val isReactionToMe = isReactionToMyEvent(it, loggedInUserHex) // Same no-`p`-tag rescue for Buzz thread replies into my messages. - val isThreadReplyToMe = isBuzzThreadReplyToMyEvent(it, loggedInUserHex) + val isThreadReplyToMe = isBuzzThreadReplyToMyEvent(it, loggedInUserHex) || isBuzzMentionOfMe(it, loggedInUserHex) // Concord CHAT (a message/reply) honors the "Messages in notifications" toggle that silences DMs // and Marmot groups above. A reaction isn't a message — regular reactions ignore that toggle, so diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Chat.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Chat.kt index 5127725c1e..d53d5f2161 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Chat.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Chat.kt @@ -58,14 +58,17 @@ fun RenderChat( isBoostedNote: Boolean = false, ) { val noteEvent = note.event ?: return - val eventContent = remember(noteEvent) { noteEvent.content } + // The newest in-place edit (Buzz, Concord, Marmot) wins — the chat room shows it, so a + // notification card or quote of the same message must too. + val content = observeChatMessageEdit(note)?.event?.content ?: noteEvent.content + val eventContent = content // Content actually handed to the media renderer. The shared renderer only shows media whose URL // appears in the text, but some NIP-C7/Concord clients (e.g. Ditto/Soapbox Armada) attach an // image purely as a NIP-92 `imeta` tag and leave the content empty. Append any imeta URL missing // from the content so those attachments render — symmetric to ChannelChat.imageMessage, which // appends the URL on send. Encrypted-blob key/nonce are already registered by URL, so the shared // pipeline fetches and decrypts them transparently. - val displayContent = remember(noteEvent) { appendMissingImetaUrls(noteEvent.content, noteEvent) } + val displayContent = remember(noteEvent, content) { appendMissingImetaUrls(content, noteEvent) } // A boosted note inside a zap/nutzap/onchain activity card is always shown as a // compact 2-line preview, even when the logged-in user is only a zap-split diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImeta.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImeta.kt index 9e62843c1d..8357b4e686 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImeta.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImeta.kt @@ -45,4 +45,24 @@ object BuzzImeta { ) fun sanitize(tags: List): List = tags.map(::sanitize) + + /** + * Buzz's clients draw an attachment only where the body links it in markdown — `![image](url)` + * or `![video](url)`, the form their composer writes — and show a bare URL as a plain link. So + * each attachment URL that stands alone in [content] becomes that markdown; one already inside + * markdown is left alone. + */ + fun markdownMediaBody( + content: String, + attachments: List, + ): String { + var out = content + attachments.forEach { tag -> + val url = tag.url + if (url.isBlank() || out.contains("]($url)")) return@forEach + val label = if (tag.properties["m"]?.firstOrNull()?.startsWith("video/") == true) "video" else "image" + out = out.replace(url, "![$label]($url)") + } + return out + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImetaTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImetaTest.kt index 6c231eb58d..68fc606c47 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImetaTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImetaTest.kt @@ -64,4 +64,23 @@ class BuzzImetaTest { assertEquals(listOf("one"), clean.properties["alt"]) assertEquals(listOf("https://a/1", "https://b/1"), clean.properties["fallback"]) } + + @Test + fun bareAttachmentUrlsBecomeBuzzMarkdown() { + val img = IMetaTag("https://ws.example/media/a.jpg", mapOf("m" to listOf("image/jpeg"))) + val vid = IMetaTag("https://ws.example/media/b.mp4", mapOf("m" to listOf("video/mp4"))) + + assertEquals( + "look ![image](https://ws.example/media/a.jpg) and ![video](https://ws.example/media/b.mp4)", + BuzzImeta.markdownMediaBody("look https://ws.example/media/a.jpg and https://ws.example/media/b.mp4", listOf(img, vid)), + ) + } + + @Test + fun urlsAlreadyInMarkdownStayAsTheyAre() { + val img = IMetaTag("https://ws.example/media/a.jpg", mapOf("m" to listOf("image/jpeg"))) + val body = "![image](https://ws.example/media/a.jpg)" + + assertEquals(body, BuzzImeta.markdownMediaBody(body, listOf(img))) + } } From e63488f664b7cec54e80ff086e7ab997e2389fa4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 1 Oct 2026 09:11:49 -0400 Subject: [PATCH 3/3] fix(buzz): audit fixes for the Buzz interop PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove leftover diagnostic statements in NotificationFeedFilter (one ran a content check on every notification candidate). - Join on a Buzz open channel joins directly: every Buzz channel carries the `closed` tag, so the button opened the invite-code dialog instead. - Profile push: a signer failure no longer crashes the channel screen, and a failed attempt is retried later instead of being marked done. - `OK false "duplicate: …"` counts as accepted, not as a refusal. - Auto-join on post happens once per channel instead of on every post until the relay's roster catches up (each re-sent 9021 and re-published 10009). - The thread screen's Buzz check is reactive (cold-start trap, as in the top bar), so uploads and typing target the workspace once it is recognized. - Only domain-label/glued NIP-19 entities are skipped as citations; path links such as njump.me/npub1… keep being cited as before. - JPEG sanitizer walks entropy-coded scans: drops metadata after a scan and ends at the real EOI, so appended payloads containing FF D9 are cut. - observeChatMessageEdit resolves its initial value once per note instead of on every recomposition; forum-comment reply links validate their ids. Co-Authored-By: Claude Opus 5.5 --- .../loggedIn/chats/minichat/MinichatScreen.kt | 11 ++-- .../relayGroup/RelayGroupTopBar.kt | 16 +++++- .../send/ChannelNewMessageViewModel.kt | 8 +++ .../commons/model/AccountRelayGroupActions.kt | 21 ++++--- .../commons/model/cache/EventCache.kt | 2 +- .../dal/NotificationFeedFilter.kt | 9 +-- .../amethyst/commons/ui/note/types/Text.kt | 5 +- .../quartz/buzz/media/BuzzMediaSanitizer.kt | 55 ++++++++++++------- .../RelayInsertConfirmationCollector.kt | 9 ++- .../quartz/nip19Bech32/Nip19Parser.kt | 15 +++-- .../buzz/media/BuzzMediaSanitizerTest.kt | 19 +++++++ .../nip10Notes/content/FindNostrUrisTest.kt | 11 +++- 12 files changed, 126 insertions(+), 55 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt index eb61bfed05..59dc042f44 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt @@ -161,12 +161,11 @@ fun MinichatScreen( val canPost by remember { derivedStateOf { composer.text.isNotBlank() } } // A thread in a Buzz workspace channel: its media must live on the workspace's own server // (the relay refuses any other image host) and its typing signal is scoped to the thread. - val buzzGroup = - remember(rootNote) { - rootNote.inGatherers - ?.firstNotNullOfOrNull { it as? RelayGroupChannel } - ?.takeIf { BuzzRelayDialect.isBuzz(it.groupId.relayUrl) } - } + // Reactive on the dialect: a relay is marked Buzz only once its first verified Buzz event lands, + // which a cold start can reach after this screen opens. + val buzzRelays by BuzzRelayDialect.flow.collectAsStateWithLifecycle() + val relayGroup = remember(rootNote) { rootNote.inGatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel } } + val buzzGroup = relayGroup?.takeIf { it.groupId.relayUrl in buzzRelays } val uploadState = remember(buzzGroup) { ChatFileUploadState( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index 8422bc9b5f..77cc0e4caa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -173,7 +173,15 @@ fun RelayGroupTopBar( if (!isBuzzRelay) return@LaunchedEffect accountViewModel.account.userMetadata .getUserMetadataFlow() - .collect { accountViewModel.account.relayGroups.shareProfileWithBuzzWorkspace(channel.groupId.relayUrl) } + .collect { + try { + accountViewModel.account.relayGroups.shareProfileWithBuzzWorkspace(channel.groupId.relayUrl) + } catch (e: Exception) { + if (e is CancellationException) throw e + // A refused/failed signature must not take down the screen; it retries next time. + Log.w("RelayGroupTopBar", "Could not share the profile with ${channel.groupId.relayUrl.url}", e) + } + } } TopBarExtensibleWithBackButton( @@ -286,8 +294,10 @@ fun RelayGroupTopBar( // is still what Buzz lists, counts and offers in its mention picker — its own client // shows Join here too, so a reader can become a member. FilledTonalButton(onClick = { - // Closed groups need an invite code; open groups join directly. - if (channel.isClosed()) { + // Closed groups need an invite code; open groups join directly. Every Buzz channel + // carries the `closed` tag, so a Buzz OPEN channel (one that doesn't require + // membership to post) joins directly too. + if (channel.isClosed() && channel.requiresMembershipToPost()) { showJoinCode = true } else { requested = true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index cba4b4a5ed..6357c2a0ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -115,6 +115,7 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip22Comments.notify import com.vitorpamplona.quartz.nip28PublicChat.base.notify import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.hTag import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag @@ -139,6 +140,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.cancellation.CancellationException @Stable @@ -630,6 +632,9 @@ open class ChannelNewMessageViewModel : } } + // Buzz channels this composer has already tried to join (see [joinBuzzChannelBeforePosting]). + private val buzzJoinAttempted: MutableSet = ConcurrentHashMap.newKeySet() + /** * Posting to a Buzz open channel joins it first. The relay takes a non-member's message there, * but channel membership is what Buzz lists, counts and offers in its mention picker, and its @@ -640,6 +645,9 @@ open class ChannelNewMessageViewModel : if (channel !is RelayGroupChannel || !BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) return if (channel.event?.isBuzzDm() == true) return if (channel.membershipOf(accountViewModel.account.userProfile().pubkeyHex).isMember()) return + // Once per channel: the relay's roster (39002) lags the join, and every post before it + // lands would otherwise re-send the kind-9021 and re-publish the kind-10009 list. + if (!buzzJoinAttempted.add(channel.groupId)) return try { accountViewModel.account.relayGroups.joinRelayGroup(channel) } catch (e: Exception) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt index 8a944a14d6..1abf949bab 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt @@ -84,6 +84,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlin.uuid.ExperimentalUuidApi @@ -137,13 +138,19 @@ class AccountRelayGroupActions( if (key in current) return if (profileSharedWith.compareAndSet(current, current + key)) break } - val fresh = - if (TimeUtils.now() - profile.createdAt < BUZZ_FRESH_PROFILE_SECS) { - profile - } else { - account.signer.sign(TimeUtils.now(), MetadataEvent.KIND, profile.tags, profile.content) - } - account.client.publish(fresh, setOf(relay)) + try { + val fresh = + if (TimeUtils.now() - profile.createdAt < BUZZ_FRESH_PROFILE_SECS) { + profile + } else { + account.signer.sign(TimeUtils.now(), MetadataEvent.KIND, profile.tags, profile.content) + } + account.client.publish(fresh, setOf(relay)) + } catch (e: Throwable) { + // Not shared (signer refused, timed out, …): forget the attempt so a later visit retries. + profileSharedWith.update { it - key } + throw e + } } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index 76a2b68b82..1ecddfa028 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -1386,7 +1386,7 @@ open class EventCache : // A Buzz forum comment counts as a reply of its thread's root post (and of the comment // it answers, when nested), so the forum list's reply count sees it. A direct reply // carries only the `reply` marker, which then IS the root. - listOfNotNull(event.threadRoot(), event.replyTo()).distinct().map { getOrCreateNote(it) } + listOfNotNull(event.threadRoot(), event.replyTo()).distinct().mapNotNull { checkGetOrCreateNote(it) } } is GitStatusEvent -> { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/dal/NotificationFeedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/dal/NotificationFeedFilter.kt index 5b30df6d39..4a9979e5e1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/dal/NotificationFeedFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/dal/NotificationFeedFilter.kt @@ -407,7 +407,6 @@ class NotificationFeedFilter( override fun feed(): List { val filterParams = buildFilterParams(account) - com.vitorpamplona.quartz.utils.Log val notifications = LocalCache.notes.filterIntoSet { _, note -> @@ -437,7 +436,6 @@ class NotificationFeedFilter( private fun innerApplyFilter(collection: Collection): Set { val filterParams = buildFilterParams(account) - com.vitorpamplona.quartz.utils.Log return collection.filterTo(HashSet()) { acceptableEvent(it, filterParams) } } @@ -490,9 +488,7 @@ class NotificationFeedFilter( val event = note.event if (event !is ChatEvent && event !is StreamMessageV2Event) return false if (!event.isTaggedUser(me)) return false - val group = LocalCache.getRelayGroupChannelForContent(note) - com.vitorpamplona.quartz.utils.Log - if (group == null) return false + val group = LocalCache.getRelayGroupChannelForContent(note) ?: return false return BuzzRelayDialect.isBuzz(group.groupId.relayUrl) } @@ -531,9 +527,6 @@ class NotificationFeedFilter( filterParams: FilterByListParams, ): Boolean { val loggedInUserHex = account.userProfile().pubkeyHex - if (it.event?.content?.startsWith("BZ-6") == true) { - com.vitorpamplona.quartz.utils.Log - } // When the user opts out of seeing Messages on the Notification tab, drop // direct/group message events (DMs and Marmot group chats) entirely. diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Text.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Text.kt index ef8420054b..c89ac89230 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Text.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Text.kt @@ -237,8 +237,11 @@ fun RenderTextEvent( /** The newest in-place edit of a chat message (Concord, Buzz or Marmot), recomposing as edits land. */ @Composable fun observeChatMessageEdit(note: Note): Note? { + // Resolved once per note, not on every recomposition: produceState's initialValue argument is + // re-evaluated each time this runs, and these feeds recompose a lot. + val initial = remember(note) { note.latestChatEdit() } val latest by - produceState(initialValue = note.latestChatEdit(), note.idHex) { + produceState(initialValue = initial, note.idHex) { note .flow() .edits.stateFlow diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizer.kt index 3e6519ed2a..d3a228fdc7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizer.kt @@ -45,6 +45,12 @@ object BuzzMediaSanitizer { private fun ByteArray.u8(i: Int) = this[i].toInt() and 0xff + /** + * Walks the JPEG marker by marker, entropy-coded scans included, so a metadata segment after a + * scan (progressive files interleave tables and scans) is dropped like one before it, and the + * file ends at the end-of-image marker that closes the last scan — not at whatever `FFD9` some + * appended payload (a motion-photo video, an extra JPEG) happens to contain. + */ fun stripJpeg(b: ByteArray): ByteArray? { if (b.size < 4 || b.u8(0) != 0xff || b.u8(1) != 0xd8) return null val out = ByteBuilder(b.size) @@ -58,17 +64,7 @@ object BuzzMediaSanitizer { val marker = b.u8(j) when { marker == 0xd9 -> { - out.write(byteArrayOf(0xff.toByte(), 0xd9.toByte()), 0, 2) - return out.toByteArray() - } - - marker == 0xda -> { - // Start of scan: entropy-coded data (and any later tables/scans) runs to the - // last end-of-image marker; whatever trails it is dropped. - val eoi = lastEoi(b) - if (eoi < j) return null - out.write(byteArrayOf(0xff.toByte()), 0, 1) - out.write(b, j, eoi + 2 - j) + out.write(EOI, 0, 2) return out.toByteArray() } @@ -77,6 +73,8 @@ object BuzzMediaSanitizer { i = j + 1 } + marker == 0xd8 -> return null + else -> { if (j + 3 > b.size) return null val len = (b.u8(j + 1) shl 8) or b.u8(j + 2) @@ -84,16 +82,38 @@ object BuzzMediaSanitizer { val end = j + 1 + len if (end > b.size) return null if (keepJpegSegment(marker, b, j + 3, end)) { - out.write(byteArrayOf(0xff.toByte()), 0, 1) + out.write(FF, 0, 1) out.write(b, j, end - j) } i = end + if (marker == 0xda) { + // Entropy-coded data runs to the next marker that isn't a stuffed byte + // (FF 00) or a restart (FF D0-D7). + val scanEnd = endOfScan(b, end) ?: return null + out.write(b, end, scanEnd - end) + i = scanEnd + } } } } return null } + private fun endOfScan( + b: ByteArray, + from: Int, + ): Int? { + var k = from + while (k + 1 < b.size) { + if (b.u8(k) == 0xff) { + val next = b.u8(k + 1) + if (next != 0x00 && next != 0xff && next !in 0xd0..0xd7) return k + } + k++ + } + return null + } + private fun keepJpegSegment( marker: Int, b: ByteArray, @@ -121,14 +141,9 @@ object BuzzMediaSanitizer { } } - private fun lastEoi(b: ByteArray): Int { - var k = b.size - 2 - while (k >= 0) { - if (b.u8(k) == 0xff && b.u8(k + 1) == 0xd9) return k - k-- - } - return -1 - } + private val FF = byteArrayOf(0xff.toByte()) + + private val EOI = byteArrayOf(0xff.toByte(), 0xd9.toByte()) private val PNG_SIGNATURE = byteArrayOf(0x89.toByte(), 'P'.code.toByte(), 'N'.code.toByte(), 'G'.code.toByte(), 0x0d, 0x0a, 0x1a, 0x0a) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt index 3c81bf8b7f..c4046ccbfc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt @@ -31,7 +31,8 @@ import com.vitorpamplona.quartz.utils.Log /** * Listens to INostrClient's relay messages and reports `OK`s for published events: [onRelayReceived] for an acceptance and, when given, * [onRelayRejected] for a definitive refusal with the relay's reason. An `auth-required:` refusal - * is NOT reported — the client authenticates and resends, so it is not the relay's final answer. + * is NOT reported — the client authenticates and resends, so it is not the relay's final answer — + * and a `duplicate:` refusal counts as an acceptance. */ class RelayInsertConfirmationCollector( val client: INostrClient, @@ -46,7 +47,9 @@ class RelayInsertConfirmationCollector( msg: Message, ) { if (msg !is OkMessage) return - if (msg.success) { + // NIP-01: "duplicate:" means the relay already has the event. Most relays send it + // with `true`, some with `false`; either way the event is there. + if (msg.success || msg.message.startsWith(DUPLICATE_PREFIX)) { onRelayReceived(msg.eventId, relay) } else if (!msg.message.startsWith(AUTH_REQUIRED_PREFIX)) { onRelayRejected?.invoke(msg.eventId, relay, msg.message) @@ -67,3 +70,5 @@ class RelayInsertConfirmationCollector( } private const val AUTH_REQUIRED_PREFIX = "auth-required:" + +private const val DUPLICATE_PREFIX = "duplicate:" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt index 04227c65e7..1eef01d926 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt @@ -314,9 +314,9 @@ object Nip19Parser { } /** - * True when the entity starting at [at] is glued to the token before it — an `npub1…` that is a - * URL's subdomain (`https://npub1….blossom.band/…`) or part of a path or word — rather than a - * reference on its own. `nostr:` and `@` prefixes are part of a reference, so they are skipped. + * True when the entity starting at [at] is glued to the word before it (`xnpub1…`, `a.npub1…`) + * rather than standing on its own. `nostr:` and `@` prefixes are part of a reference, so they are + * skipped; a `/` is not glue, so a path link like `njump.me/npub1…` still counts as cited. */ private fun isEmbeddedAt( content: String, @@ -327,9 +327,12 @@ object Nip19Parser { if (j >= 6 && content.regionMatches(j - 6, "nostr:", 0, 6, ignoreCase = true)) j -= 6 if (j == 0) return false val c = content[j - 1] - return c.isLetterOrDigit() || c == '/' || c == '.' || c == '-' || c == '_' || c == '=' || c == '?' || c == '&' || c == '#' + return c.isLetterOrDigit() || c == '.' || c == '-' || c == '_' } + /** The entity is a domain label — `npub1….blossom.band` — so it names a host, not a person. */ + private fun isDomainLabel(additionalChars: String): Boolean = additionalChars.length > 1 && additionalChars[0] == '.' && additionalChars[1].isLetterOrDigit() + private fun allBech32( content: String, from: Int, @@ -371,7 +374,9 @@ object Nip19Parser { fun parseAllStandalone(content: String): List { val returningList = mutableListOf() forEachNip19Match(content, FIXED_58_PREFIXES, VARIABLE_PREFIXES, standaloneOnly = true) { type, key, additionalChars -> - parseComponents(type, key, additionalChars)?.entity?.let { returningList.add(it) } + if (!isDomainLabel(additionalChars)) { + parseComponents(type, key, additionalChars)?.entity?.let { returningList.add(it) } + } } return returningList } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizerTest.kt index 74ffa32777..36d60cf45a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizerTest.kt @@ -91,4 +91,23 @@ class BuzzMediaSanitizerTest { assertContentEquals(notJpeg, BuzzMediaSanitizer.sanitize(notJpeg, "image/jpeg")) assertEquals(true, BuzzMediaSanitizer.handles("IMAGE/JPEG")) } + + @Test + fun metadataAfterAScanIsDroppedToo() { + val secondScan = seg(0xda, bytes(1, 1, 0, 0, 63, 0)) + bytes(0x77, 0x01) + val input = bytes(0xff, 0xd8) + jfif + dqt + sosAndData + exif + seg(0xc4, ByteArray(5) { 2 }) + secondScan + eoi + + val clean = BuzzMediaSanitizer.sanitize(input, "image/jpeg") + + assertContentEquals(bytes(0xff, 0xd8) + jfif + dqt + sosAndData + seg(0xc4, ByteArray(5) { 2 }) + secondScan + eoi, clean) + } + + @Test + fun appendedPayloadEndsAtTheRealEndOfImage() { + // A motion photo appends a video (or another JPEG) that can itself contain FF D9. + val appended = bytes(0x00, 0x11, 0xff, 0xd9, 0x22) + val input = bytes(0xff, 0xd8) + jfif + dqt + sosAndData + eoi + appended + + assertContentEquals(bytes(0xff, 0xd8) + jfif + dqt + sosAndData + eoi, BuzzMediaSanitizer.sanitize(input, "image/jpeg")) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/content/FindNostrUrisTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/content/FindNostrUrisTest.kt index ee392fa8bd..6985559b3f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/content/FindNostrUrisTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/content/FindNostrUrisTest.kt @@ -35,8 +35,15 @@ class FindNostrUrisTest { } @Test - fun npubInAPathIsNotCited() { - assertTrue(findNostrUris("see https://example.com/p/$npub").isEmpty()) + fun npubGluedToAWordIsNotCited() { + assertTrue(findNostrUris("x$npub").isEmpty()) + } + + @Test + fun aPathLinkToAProfileIsStillCited() { + // njump-style links have always been cited; only domain labels are excluded. + assertEquals(1, findNostrUris("see https://njump.me/$npub").filterIsInstance().size) + assertEquals(1, findNostrUris("end of sentence nostr:$npub.").filterIsInstance().size) } @Test