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..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 @@ -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,22 @@ 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. + // 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 { + 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 +264,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 +279,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..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 @@ -161,8 +161,28 @@ 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. 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) return@LaunchedEffect + accountViewModel.account.userMetadata + .getUserMetadataFlow() + .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( title = { @@ -269,10 +289,15 @@ 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()) { + // 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 @@ -418,14 +443,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..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 @@ -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 @@ -112,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 @@ -136,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 @@ -419,6 +424,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 +512,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 +632,44 @@ 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 + * 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 + // 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) { + 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 +681,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() @@ -863,7 +909,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 ef4b75348f..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 @@ -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 @@ -160,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 @@ -697,8 +699,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 @@ -810,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 }, ) @@ -1009,7 +1019,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..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 @@ -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,15 +2240,18 @@ 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) { + // 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)) } 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..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 @@ -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 @@ -82,6 +83,8 @@ 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.coroutines.flow.update import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlin.uuid.ExperimentalUuidApi @@ -111,14 +114,59 @@ class AccountRelayGroupActions( account.follow(channel) } + // "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. + * + * 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. + */ + suspend fun shareProfileWithBuzzWorkspace(relay: NormalizedRelayUrl) { + if (!account.isWriteable()) return + val profile = account.userMetadata.getUserMetadataEvent() ?: return + 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 + } + 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 + } + } + /** * 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)) } @@ -663,3 +711,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 3d05686f7e..24b28d472c 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 @@ -1385,6 +1385,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().mapNotNull { checkGetOrCreateNote(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/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/notifications/dal/NotificationFeedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/dal/NotificationFeedFilter.kt index a6c26603e3..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 @@ -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 @@ -474,6 +475,23 @@ 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) ?: 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. * @@ -666,7 +684,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/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 d0906b6146..0bcd36e69f 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/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/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..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 @@ -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,21 @@ 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 = initial, 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..8357b4e686 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImeta.kt @@ -0,0 +1,68 @@ +/* + * 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) + + /** + * 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/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..d3a228fdc7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizer.kt @@ -0,0 +1,191 @@ +/* + * 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 + + /** + * 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) + 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(EOI, 0, 2) + return out.toByteArray() + } + + marker in 0xd0..0xd7 || marker == 0x01 -> { + out.write(byteArrayOf(0xff.toByte(), marker.toByte()), 0, 2) + 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) + 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(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, + 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 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) + + // 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..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 @@ -29,10 +29,14 @@ 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 — + * and a `duplicate:` refusal counts as an acceptance. */ 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 +46,13 @@ class RelayInsertConfirmationCollector( msgStr: String, msg: Message, ) { - if (msg is OkMessage && msg.success) { + if (msg !is OkMessage) return + // 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) } } } @@ -59,3 +68,7 @@ class RelayInsertConfirmationCollector( client.removeConnectionListener(clientListener) } } + +private const val AUTH_REQUIRED_PREFIX = "auth-required:" + +private const val DUPLICATE_PREFIX = "duplicate:" 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..1eef01d926 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,26 @@ object Nip19Parser { } } + /** + * 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, + 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 == '_' + } + + /** 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, @@ -345,6 +366,21 @@ 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 -> + if (!isDomainLabel(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..68fc606c47 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImetaTest.kt @@ -0,0 +1,86 @@ +/* + * 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"]) + } + + @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))) + } +} 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..36d60cf45a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizerTest.kt @@ -0,0 +1,113 @@ +/* + * 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")) + } + + @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/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..6985559b3f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/content/FindNostrUrisTest.kt @@ -0,0 +1,56 @@ +/* + * 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 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 + 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) + } +}