mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
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:
+98
-10
@@ -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
|
||||
|
||||
+5
@@ -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"
|
||||
|
||||
+4
@@ -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(
|
||||
|
||||
+3
-2
@@ -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(
|
||||
|
||||
+4
-4
@@ -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()
|
||||
|
||||
|
||||
+35
-11
@@ -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) {
|
||||
|
||||
+18
-14
@@ -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)
|
||||
|
||||
+33
-3
@@ -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.
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
+11
-1
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+41
-12
@@ -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.
|
||||
|
||||
+3
-1
@@ -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,
|
||||
|
||||
+49
-2
@@ -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 ``.
|
||||
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
|
||||
|
||||
+14
-4
@@ -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)
|
||||
}
|
||||
|
||||
+14
-12
@@ -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) {
|
||||
|
||||
+15
-1
@@ -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 ``.
|
||||
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 {
|
||||
|
||||
+53
-2
@@ -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
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
+21
-2
@@ -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?,
|
||||
|
||||
+16
@@ -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> =
|
||||
|
||||
+9
-1
@@ -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) {
|
||||
|
||||
+19
-1
@@ -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
|
||||
|
||||
+36
-3
@@ -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.
|
||||
|
||||
+81
@@ -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())
|
||||
}
|
||||
}
|
||||
+25
@@ -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>
|
||||
|
||||
+5
-2
@@ -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
-1
@@ -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()
|
||||
|
||||
+2
@@ -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)
|
||||
|
||||
+7
-4
@@ -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(
|
||||
|
||||
@@ -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 — ``
|
||||
* or ``, 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, "")
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
+191
@@ -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)
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -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:"
|
||||
|
||||
+24
@@ -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
|
||||
|
||||
+25
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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>()
|
||||
|
||||
+8
-2
@@ -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  and ",
|
||||
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 = ""
|
||||
|
||||
assertEquals(body, BuzzImeta.markdownMediaBody(body, listOf(img)))
|
||||
}
|
||||
}
|
||||
+113
@@ -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"))
|
||||
}
|
||||
}
|
||||
+37
@@ -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<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
|
||||
fun perGroupFiltersAreMergedUnderTheCapAndSentAgainAtOnce() =
|
||||
kotlinx.coroutines.test.runTest {
|
||||
|
||||
+56
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user