Merge pull request #4295 from vitorpamplona/fix/buzz-interop

fix(buzz): Buzz Desktop interop fixes from a live two-way test
This commit is contained in:
Vitor Pamplona
2026-10-01 09:13:43 -04:00
committed by GitHub
43 changed files with 1332 additions and 105 deletions
@@ -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,6 +357,12 @@ class AndroidMediaUploader(
)
val finalState =
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,
@@ -321,12 +370,13 @@ class AndroidMediaUploader(
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
@@ -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<BlossomUploadResult>(body)
}
private const val SHA256_HEADER = "X-SHA-256"
@@ -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(
@@ -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(
@@ -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()
@@ -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,7 +311,9 @@ private fun RecipientDeliveryRow(
private fun RelayDeliveryRow(
relay: NormalizedRelayUrl,
accepted: Boolean,
rejection: String? = null,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -321,8 +325,21 @@ private fun RelayDeliveryRow(
maxLines = 1,
)
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,
)
}
}
}
@Composable
@@ -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) {
@@ -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<Note?>(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)
@@ -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(
}
},
)
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.
@@ -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,
@@ -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)
},
)
}
@@ -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,6 +443,9 @@ fun RelayGroupTopBar(
}
},
)
// 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 = {
@@ -426,6 +454,7 @@ fun RelayGroupTopBar(
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.
@@ -135,7 +135,9 @@ fun ChannelFileUploadDialog(
onceUploaded = onUpload,
)
if (state.lockedServer == null) {
accountViewModel.account.settings.changeDefaultFileServer(state.selectedServer)
}
accountViewModel.account.settings.changeStripLocationOnUpload(state.stripMetadata)
},
onCancel,
@@ -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<SelectedMedia>) {
// 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<GroupId> = 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<out Event>? {
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
@@ -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)
}
@@ -230,6 +230,7 @@ private fun ImageVideoPostChat(
)
}
if (fileUploadState.lockedServer == null) {
SettingsRow(Res.string.file_server, Res.string.file_server_description) {
TextSpinner(
label = "",
@@ -243,6 +244,7 @@ private fun ImageVideoPostChat(
onSelect = { fileUploadState.selectedServer = fileServers[it] },
)
}
}
if (fileUploadState.multiOrchestrator?.hasCompressible() == true) {
Column(
@@ -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<ServerName?>(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
}
@@ -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 {
@@ -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<Set<String>>(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<MetadataEvent>(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>): 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
@@ -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
@@ -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<User>? = null,
var eTags: List<Note>? = null,
var dao: Dao,
val userMentionsAsNames: Boolean = false,
) {
val directMentions = mutableSetOf<HexKey>()
val directMentionsNotes = mutableSetOf<Note>()
@@ -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?,
@@ -33,6 +33,22 @@ enum class ServerType {
Blossom,
NIP95,
NIP96,
/**
* A Buzz workspace's own Blossom endpoint (`https://<workspace>/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<ServerName> =
@@ -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<Note>) {
// 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) {
@@ -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
@@ -76,7 +76,19 @@ data class ChatDelivery(
val acceptedRelays: Set<NormalizedRelayUrl> = emptySet(),
val recipients: List<RecipientDelivery>? = null,
val sendState: ChatSendState = ChatSendState.SENT,
// Relays that answered `OK false`, with their reason. A later acceptance clears the entry.
val rejectedRelays: Map<NormalizedRelayUrl, String> = 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<RecipientDelivery>?
get() = recipients?.filterNot { it.isSelf }
@@ -137,7 +149,10 @@ class ChatDeliveryTracker(
private val retries = mutableMapOf<HexKey, suspend () -> 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.
@@ -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())
}
}
@@ -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"))
}
}
@@ -5469,6 +5469,7 @@
<string name="marmot_invite_device_rejected">No relay accepted the KeyPackage. Check your relay settings and try again.</string>
<string name="chat_delivery_sending">Sending\u2026</string>
<string name="chat_delivery_failed">Could not be sent. Tap to try again.</string>
<string name="chat_delivery_rejected">The relay refused this message</string>
<plurals name="marmot_unread_messages">
<item quantity="one">%1$d new message</item>
<item quantity="other">%1$d new messages</item>
@@ -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
@@ -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()
@@ -461,6 +461,8 @@ class AccountFeedContentStates(
}
fun deleteNotes(newNotes: Set<Note>) {
notifications.deleteFromFeed(newNotes)
notificationsFollowing.deleteFromFeed(newNotes)
homeLive.deleteFromFeed(newNotes)
homeNewThreads.deleteFromFeed(newNotes)
homeReplies.deleteFromFeed(newNotes)
@@ -764,9 +764,12 @@ 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
@@ -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<IMetaTag>): List<IMetaTag> = 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<IMetaTag>,
): 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
}
}
@@ -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)
}
}
@@ -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:"
@@ -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
@@ -214,6 +214,23 @@ class RelayReqRefusals(
fun disallowedKinds(relay: NormalizedRelayUrl): Set<Int> = 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
@@ -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)
@@ -242,6 +242,7 @@ object Nip19Parser {
content: String,
fixed58Prefixes: Array<String>,
variablePrefixes: Array<String>,
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<Entity> {
val returningList = mutableListOf<Entity>()
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<Entity> {
val returningList = mutableListOf<Entity>()
@@ -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<String> = 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<String> = 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
@@ -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)))
}
}
@@ -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"))
}
}
@@ -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,6 +82,42 @@ 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<ReqCmd>()
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
@@ -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<NPub>().size)
assertEquals(1, findNostrUris("end of sentence nostr:$npub.").filterIsInstance<NPub>().size)
}
@Test
fun standaloneReferencesAreStillCited() {
assertEquals(1, findNostrUris("hi nostr:$npub").filterIsInstance<NPub>().size)
assertEquals(1, findNostrUris("hi @$npub, there").filterIsInstance<NPub>().size)
assertEquals(1, findNostrUris("$npub at the start").filterIsInstance<NPub>().size)
assertEquals(1, findNostrUris("($npub)").filterIsInstance<NPub>().size)
}
}