verifyOts wrote otsVerification = Verifying before the suspending blockchain
verifyState() call and only overwrote it with the real verdict afterwards. That
verification runs inside LoadOts's cancellable LaunchedEffect, so a row scrolling
off-screen cancels the coroutine at the network suspension point — the verdict
write never happens and the note is left stuck at Verifying. cacheVerifyOts
treats Verifying as terminal, so earliestOtsVerifiedTime then returns null and the
confirmed-timestamp pill silently vanishes for the note's lifetime. The old
VerificationStateCache had the same write but its LRU eventually evicted the stuck
entry; anchoring the verdict on the long-lived note removed that recovery.
Store only terminal verdicts (Verified / Error / NetworkError). A cancelled or
in-flight verification leaves the field null and simply retries on the next read;
the cost is at worst two concurrent first-time verifications, already benign.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014D5fenZbAhCbDiwv7Rtpvj
NIP-03 OpenTimestamps attestations (kind 1040) were stored loosely in the main
note cache and found via a full-cache scan, with their blockchain verdicts held
in a separate, id-keyed VerificationStateCache LRU. Nothing tied either to the
lifecycle of the note being timestamped, so a deleted/pruned note leaked its
attestations, and consume(OtsEvent) invalidated the attestation's own
(observer-less) flow instead of the target's — so a live-arriving proof never
pinged the target's UI.
Mirror the recent edits→Note migration:
- Note gains a `timestamps` child collection (like `edits`/`reactions`), wired
into clearChildLinks/removeNote, so an attestation survives exactly as long as
its target and is collected when the target is pruned or deleted.
- consume(OtsEvent) anchors the proof on its target via the `e` tag and
invalidates the target's `ots` flow; unlinkAndRemove detaches it symmetrically.
- Each attestation memoizes its own verdict in `Note.otsVerification`, so the
result shares the note's lifecycle. This replaces VerificationStateCache
(deleted) and the full-cache scan: the OTS pill now folds `note.timestamps`
via the new Note.earliestOtsVerifiedTime / cacheVerifyOts helpers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014D5fenZbAhCbDiwv7Rtpvj
Every chat composer in Amethyst already had a picture/media attach button
(SelectFromGallery) except the minichat "thread" screen — the kind-1111
reply composer opened from the "N replies" chip — which was text-only. This
brings it to parity with every other chat.
- quartz: ChannelChat.imageReply() — a kind-1111 thread reply carrying
encrypted image imeta(s), combining reply()'s NIP-22 pointers with
imageMessage()'s ciphertext-URL/imeta handling (+ round-trip test).
- commons: ConcordActions.buildChannelImageReply().
- Account.sendMinichatReply() now accepts imetas and routes per backend:
Concord sends an encrypted image reply; NIP-28/NIP-29 public chats append
the URL to the content and carry a plaintext imeta on the comment; Buzz
appends the URL to the stream message content.
- Extract toConcordImeta()/toPlainImetas() into a shared UploadImetas.kt so
the minichat and Concord composers build imeta the same way.
- MinichatScreen: add the SelectFromGallery leading icon + ChatFileUpload
dialog, encrypting only when the backend is end-to-end (Concord).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D8FD5xm8nyEKzd8dk9VfT1
Videos in the gallery now show a decoded first frame (Coil
VideoFrameDecoder) with a play badge instead of a generic glyph; the same
preview is reused in the detail header, with a type-glyph fallback for
blobs Coil can't decode (e.g. HLS playlists).
Tapping an image or video tile now opens a full-screen viewer: images are
zoomable/pannable (engawapg zoomable, matching ZoomableImageDialog) and
videos play inline. Its top bar carries back, a new share action (system
share sheet for the blob URL), and a button that reveals the file's
storage matrix and sync/copy/open/share/report/delete actions in a bottom
drawer. Non-visual blobs (PDFs, arbitrary files) still open straight to
that action sheet. The actions list is now a shared BlobActionsContent so
the sheet and the viewer drawer stay identical.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDLTQZM6yX13EwaYFNWTz7
Replace the single-column list of large blob cards with an adaptive
thumbnail grid so many files no longer mean an endless scroll. Each tile
shows an image preview (or a type glyph) plus a corner badge summarizing
how many of the user's servers hold it (green check when on all, amber
cloud with a present/total count otherwise).
Tapping a tile opens a bottom sheet with the file's details: hash,
type/size, the per-server storage matrix ("Stored on"), the sync
(mirror-to-missing) button, and the copy/open/report/delete actions that
previously lived behind the card's overflow menu.
The ViewModel is unchanged — only the presentation layer moved from
list-of-cards to grid-plus-detail-sheet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDLTQZM6yX13EwaYFNWTz7
Audit of the three edit paths (feed 1010 / Buzz 40003 / Concord 3302) found:
1. Bug (feed regression): a deleted edit kept overlaying its message. Edits
anchor on the target's Note.edits with no `replyTo` back-link, and
removeNote didn't cover `edits`, so unlinkAndRemove never dropped them — the
old cache-scan resolver dropped deleted edits for free, Note.edits did not.
Fix: removeNote now also removeEdit()s, and unlinkAndRemove resolves the
edit's `e`-tag target and unlinks it there (editedTargetIdOf covers all
three kinds). New test: deleting an edit un-overlays and unlinks it.
2. Perf: every chat row ran two edits-flow collectors (observeConcordEdit +
observeBuzzEdit). A message is only ever one kind, so they're merged into a
single observeChatEdit that resolves latestConcordEdit() ?: latestBuzzEdit()
— one collector per row, dispatched by the winning edit's event type.
3. Nits: latestBuzzEdit now tie-breaks by idHex (deterministic on same-second
edits, matching Concord); dropped a redundant takeIf in latestConcordEdit.
The author check stays at read time on purpose: an edit can be consumed before
its target loads (author unknown), so an attach-time gate would wrongly drop
early-arriving legit edits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
findLatestModificationForNote (and the Buzz resolver) were pure Note.edits
filters with no LocalCache state — they only lived there for historical
reasons (back when resolving edits meant scanning the whole cache). Now that
every edit is a hard-referenced child of its message, resolution is a cheap
in-memory fold that belongs on the note.
New NoteEditOverlays.kt collects all three as Note extensions, so every edit
kind resolves the same way and none touches LocalCache:
- Note.textNoteModifications() (1010, author-only + NIP-40, version list)
- Note.latestBuzzEdit() (40003, author-only, newest by created_at)
- Note.latestConcordEdit() (3302, author-only, newest by CORD-02 send time)
Callers updated: observeEdits, observeNoteModifications, observeBuzzEdit,
observeConcordEdit, and the Buzz test. observeConcordEdit also drops its
early-return-before-produceState guards (a conditional-hook hazard) since the
resolver returns null for a non-Concord note anyway. LocalCache no longer
carries any edit-filtering logic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
It existed to serve observeEdits a cheap synchronous value (an LRU read)
distinct from the expensive IO-only findLatestModificationForNote cache scan.
Since edits fold from Note.edits, findLatestModificationForNote is itself
cheap and thread-safe, and cachedModificationEventsForNote had become a plain
alias for it. observeEdits now calls findLatestModificationForNote directly —
the same function observeNoteModifications already uses — and the LocalCache +
AccountViewModel aliases are removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
The first cut appended :geode:test to the build-desktop matrix command.
That was wrong twice over: geode is JVM-only, so it ran 3× across the
ubuntu/macos/windows matrix, and — because org.gradle.parallel=true —
its default suite's CPU-heavy throughput benchmarks (a 1M-event mirror
sync, WireReqFloor, NegentropyServerReconcile) ran concurrently with the
timing-sensitive quartz relay-client tests, flaking
NostrClientReqBypassingRelayLimitsTest.denseSecondBeyondCapIsSteppedPastWithoutStalling.
Move :geode:test into its own test-geode job (needs: lint, ubuntu, JVM
21) so it runs once and its benchmark load can't starve another module's
timing assertions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCdJwdhGtmLZ12ViS56S3k
geode was runnable only via ./gradlew :geode:run and was absent from CI.
Give it the same release process as the amy CLI (it's the same kind of
application-plugin JVM module), plus the pieces a long-running server
daemon needs that a one-shot CLI does not.
- Main.kt: add terminal --version/-V and --help/-h flags so a packaged
binary has a fast, exit-0 command (Homebrew test block, package smoke
checks, Docker healthcheck).
- build.gradle.kts: jlinkRuntime + geodeImage (portable flat app-image
with a bundled JRE, plus config.example.toml + geode.service under
share/) + jpackageDeb/jpackageRpm, mirroring cli/. No Compose to
exclude — geode depends only on :quartz.
- Dockerfile + .dockerignore: multi-stage image (gradle installDist ->
temurin JRE), the primary channel for relay operators.
- packaging/: systemd unit, macOS hardened-runtime entitlements, and a
reference Homebrew formula.
- scripts/asset-name.sh: geode_asset_name/collect_geode_assets under the
canonical geode-<version>-<family>-<arch>.<ext> scheme.
- create-release.yml: build-geode matrix (tarball + deb/rpm + no-JRE jvm
bundle, with a serve+NIP-11 smoke test of the jlink image) and a
docker-geode job pushing ghcr.io/<owner>/geode:<version> (+ :latest).
- bump-homebrew-geode-formula.yml: auto-sync the reference formula on
stable releases.
- build.yml: run :geode:test in CI (it ran in no workflow before).
- README.md + plans/2026-07-24-geode-release.md: operator docs + design.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCdJwdhGtmLZ12ViS56S3k
The Buzz edit overlay applied the newest kind-40003 by created_at regardless
of who signed it, so a 40003 signed by anyone — targeting someone else's
message — would rewrite that message in every reader's UI. The send side
already gates Edit to your own messages, but the apply side re-checked
nothing and effectively trusted the relay to reject cross-author edits.
Enforce author-only on apply, matching feed (1010) and Concord (3302) edits:
observeBuzzEdit now resolves through LocalCache.findLatestBuzzEditForNote,
which keeps only edits whose author is the original message's author (newest
by created_at — Buzz's own last-write-wins rule otherwise). There is no Buzz
feature that edits another user's message; moderation is delete/hide.
Added a test: a verified forged edit by a different author lands in the store
but never overrides the message, while the real author's later edit does.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
All three edit kinds now anchor on the message they edit via the same
Note.edits collection, instead of each maintaining its own store:
- Feed edits (kind 1010): consume(TextNoteModificationEvent) calls
editedNote.addEdit(note); findLatestModificationForNote folds note.edits
(author-only, NIP-40 expiry) instead of scanning the whole cache. Drops
the O(all-notes) scan and the 20-entry modificationCache LRU;
cachedModificationEventsForNote is now synchronous (no Loading state).
- Buzz edits (kind 40003): consume(StreamMessageEditEvent) calls
target.addEdit(note); observeBuzzEdit reads note.edits (newest by
created_at, no author gate — Buzz's own rule). Removes the channel-keyed
BuzzWorkspaceState edit store, its editUpdates/editFor/effectiveContentFor/
addEdit and the pruneEdits reaping (edits now prune with their message).
- Concord edits (kind 3302): already on note.edits.
Each reader keeps its own semantics by filtering note.edits on its event
type; the shared field only unifies storage + lifecycle, so an edit lives
exactly as long as the message it edits. Buzz edit tests rewritten against
note.edits (6/6 green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
- don't double-return the player if register() throws
- guarantee pool teardown even if a session retire throws
- simplify pool ownership code, one funnel and paired counters
- fix(playback): remove pool from livePools after its own teardown
- fix(playback): floor decoder decrements and report teardown drift
- fix(playback): own the player across the acquire-to-register window
- fix(playback): give every pooled player exactly one owner
Sweep of Kotlin compiler warnings in every module's main source sets
(quartz, commons, cli, desktopApp, amethyst, nappletHost).
Genuine code fixes:
- Drop unnecessary !!/safe-calls and redundant elvis/casts (OkHttp's
now-non-null `body`, smart-cast callbacks, non-null String receivers).
- Remove provably-redundant conditions (`canvas == null` after a
non-null content check; `account != null` implied by `canModerate`).
- Migrate deprecated kotlinx.collections.immutable persistent ops
(add/remove/put/addAll -> adding/removing/putting/addingAll).
- Migrate LocalClipboardManager -> LocalClipboard (+ scoped setText),
ContextCompat.startActivity -> context.startActivity, TabRow ->
SecondaryTabRow, and @ConsistentCopyVisibility on a private-ctor data class.
- Delete dead ReceiveDialog.onGenerate param (never invoked).
- Fix a platform-Boolean type-mismatch on a ThreadLocal read.
Deprecations with no available successor are narrowly @Suppress-ed with
a reason: androidx.security.crypto (EncryptedSharedPreferences/MasterKey),
androidx.privacysandbox.ui, WebView.databaseEnabled, BluetoothDevice
.connectGatt, media3 setEnableAudioTrackPlaybackParams, FirebaseMessaging
.token, and InputMethodManager.SHOW_IMPLICIT.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Xb9YbBqhdZsHxMzitmyvn
Geode hard-wired the SQLite EventStore. Add a `[database].backend`
selector (and `--store` CLI flag) so an operator can choose the store
implementation:
- "sqlite" (default): the SQLite EventStore, unchanged.
- "fs": quartz's filesystem FsEventStore, rooted at [database].file.
- any other value: a fully-qualified class name of a custom
IEventStore on the classpath, instantiated reflectively via one of
`(NormalizedRelayUrl?, IndexingStrategy)`, `(NormalizedRelayUrl?)`,
or `()` — the "plug in anything" escape hatch.
Store construction moves into a new StoreFactory (mirrors cli's
StoreFactory) shared by the serve path and the import/export verbs, so
both open the same store from the same config. The SQLite-only
`PRAGMA optimize` maintenance loop now runs only when the resolved
store is the SQLite one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GG3TBvLUv5uB5js1naG5sc
LocalCache.notes is a soft cache, and a Concord rumor is decrypted exactly
once per session (the community session dedups re-delivered wraps), so a
kind-3302 edit left orphaned there could be GC'd on navigation and never
re-downloaded — the message would silently revert to its pre-edit text.
Resetting the channel EOSE doesn't help: the re-delivered wrap is swallowed
by the session's isNew dedup, so its rumor never re-emits.
Fix it the way reactions/replies already survive: attach the edit to the
message it edits. consume(ConcordChatEditEvent) now calls target.addEdit(note),
so the edit is held for exactly as long as its channel-retained message (and
released with it via clearChildLinks). observeConcordEdit reads note.edits
directly — author-matching, latest by CORD-02 §4 send time — instead of
scanning/observing the soft cache.
- Note: new hard-held `edits` collection + addEdit/removeEdit, wired into
clearChildLinks like the other child-event links.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
LocalCache.computeReplyTo had two `is StreamMessageV2Event ->` branches in the
same `when`. The `when` matches the first, so the second (the older
buzzThreadRoot+buzzThreadReply variant) was dead code — the condition Sonar
flags as duplicating the earlier one. The surviving first branch is the newer,
deliberate Concord-style threading behavior that links a 40002 thread reply into
its parent's replies via buzzThreadReply. Also drops the now-unused
buzzThreadRoot import.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoYFvsXvPigRWebuoGiRoG
- rememberNoteMenuActions is now the single source for the note menu; both the
⋮ overflow (ShareMenu) and the feed right-click ContextMenuArea render the same
items (copy ×5 / broadcast / mute / report).
- LocalSnackbarHost exposes the app SnackbarHostState so moderation actions show a
confirmation: 'Muted user', 'Report sent', 'Reported & muted', 'Broadcast to
relays', and failure toasts. Wired for the note menu + profile ⋮ actions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Report/mute actions published silently with no feedback. Add a DesktopModeration
logger: every report/mute logs kind+id+relay-count on publish, WARNs when there
are 0 connected relays (so a dropped publish is visible instead of silent), and
signing/publish failures are caught + logged (were swallowed by the launching scope).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Manual testing showed the note moderation actions were hard to find:
- Provide LocalDesktopIAccount on MainContent's own (non-null) provider so every
deck/feed/profile/settings surface reliably resolves the account.
- Add a right-click ContextMenuArea on feed notes: Mute user / Report… / Copy text
(moderation items for other authors on a writeable account).
- Swap the note action-row Share icon for a MoreVert (⋮) overflow — the menu it
opens already carries copy/broadcast + mute/report, matching the profile ⋮.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Going / Maybe / Can't go buttons in the calendar feed card wrapped
to two lines because the default button content padding (24dp horizontal)
left no room for "Can't go" in an equal-weight third of the row.
Tighten the content padding, shrink the row spacing, and constrain each
label to a single line so the three buttons fit horizontally.
The parent message shown above a reply in the MessagingStyle notification was
unconditionally labeled "Me" with the account's avatar. That's wrong whenever
the account is notified as the *root* author of a thread but the direct parent
belongs to someone else — e.g. Vitor starts a thread, fiatjaf replies, a third
person replies to fiatjaf: fiatjaf's note was rendered as Vitor. This surfaced
through the NIP-22 comment and public-chat reply paths, which notify on
root-authorship, not just direct-parent authorship.
ReplyNotification now receives the parent Note (not a bare content string) and
resolves its actual author: attributed to the MessagingStyle `me` Person only
when the parent truly is the account's, otherwise shown as the real author with
their name + avatar, observed for enrichment like the replier. postConversation
reuses the `me` Person for a self-authored parent so it still renders as you.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r