Audit of the rich-text convergence found a behavior regression: the Android
RichTextSegmentRenderer.Payment ignored canPreview and always rendered the
interactive MayBeInvoicePreview / MayBeWithdrawal UI. The original
RenderWordWithoutPreview switchboard deliberately showed plain text for
invoice and withdraw segments when previews are suppressed ("Don't offer to
pay invoices" / "Don't offer to withdraw"). Branch on canPreview for those two;
Cashu and Clink stay unconditional (they decode locally, network only on tap),
matching the original.
Verified: :amethyst play debug compiles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
The pre-push spotless gate already skips itself (warn + exit 0) when
`./gradlew spotlessApply` fails for infra reasons, so a restricted sandbox
can't strand a push over formatting. But its detection regex only covered
dependency-resolution/network errors, not the case where the Gradle wrapper
can't download the pinned distribution itself — e.g. an egress-proxy 40x on
`gradle-<ver>-bin.zip`. That surfaced as a hard BLOCK on an XML-only change
even though CI's spotlessCheck would pass.
Add the distribution-bootstrap markers (the `gradle-<ver>-bin.zip` URL, the
Java `Server returned HTTP response code` download exception, and "could not
install gradle") to the infra-skip regex. These strings only appear when
gradlew failed before running any task, so they can't mask a real
formatting/compile failure (verified: a spotlessKotlinCheck violation still
blocks).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157AWJNEzybTuegstbWiQ6B
concord_invite_failed_incompatible is shown for the Unreadable status,
which fires both for a genuinely newer bundle format AND for the far more
common case where the fragment's unlock token does not decrypt the
published kind-33301 bundle (a stale link, or a coordinate re-posted under
a new token). The old copy asserted "created with a newer version of the
app", which is usually wrong, and suggested "try again after updating"
even though this state is non-retryable.
Reword to lead with the likely cause (outdated/replaced link), keep the
newer-version case as a secondary possibility, and point the user at
getting a fresh invite link.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157AWJNEzybTuegstbWiQ6B
Amethyst's ClickableUrl now delegates plain http(s) links to the shared
commons ClickableUrl (one LocalUriHandler-based implementation for both front
ends); only the Android-specific blossom: case (opening a Blossom media URI via
an Intent) stays local. The public signature is unchanged, so all 12 call sites
are untouched.
Verified: :amethyst play debug compiles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
Desktop's ClickableLink and Amethyst's ClickableUrl were near-identical: the only
real differences are mouse-first styling (Desktop underlines + shows a hand
cursor) and the open mechanism. But LocalUriHandler.openUri opens the browser on
Android AND Desktop (and the mail client for mailto:), so the "open a link"
logic never needed to be platform-specific.
Add ClickableUrl/ClickableEmail to commons/ui/components on LocalUriHandler, with
an `underline` flag so each front end keeps its exact look. Desktop's rich-text
renderer now reuses them (underline = true) for url/email/link-preview/withdraw
and its bespoke ClickableLink is deleted. Amethyst keeps its own blossom-intent-
aware ClickableUrl (blossom never applies to plain links, so this path is
equivalent); Phone stays platform-specific (Android dials, Desktop has no dialer).
Verified: :commons JVM and :desktopApp compile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
Closes the last rich-text fidelity residual. url/email/phone were rendered
generically by the shared core via RichTextInteractions callbacks, losing each
front end's per-type styling and open behavior. They now go through the
RichTextSegmentRenderer strategy:
- Add Url(url, displayText)/Email(address)/Phone(number) to the contract (with
plain-text defaults).
- Core routes LinkSegment(no-preview)/SchemelessUrl -> Url, Email -> Email,
Phone -> Phone; drop the in-core ClickableSpan.
- Amethyst renders them with ClickableUrl/ClickableEmail/ClickablePhone (blossom
intent + dial preserved); Desktop with ClickableLink / underlined mailto / plain
phone text.
- RichTextInteractions now carries only onClickHashtag (the one segment the core
draws itself, with shared icons); the onOpen* callbacks are gone.
Verified: :commons JVM, :desktopApp, :amethyst play debug compile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
Replaces the two forked cached parsers (amethyst CachedRichTextParser on
android.util.LruCache + desktop DesktopCachedRichTextParser on ConcurrentLruCache
with a naive isMarkdown) with one shared object in commons/jvmAndroid/richtext,
built on quartz ConcurrentLruCache and keeping amethyst's CommonMark-aware
computeIsMarkdown and content-addressed key (content+tags+callbackUri+authorPubKey).
- Add ConcurrentLruCache.trimToSize(maxItems) (+ tests) for the onTrimMemory path.
- Repoint all amethyst callers (incl. the markdown unit test) and both desktop
callers; delete both forks.
Verified: :commons JVM, :desktopApp, :amethyst play debug + unit tests compile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
Implements the cross-platform RichTextSegmentRenderer contract on Desktop
(mouse-first) and converges Desktop onto the shared commons RichTextViewer:
- DesktopRichTextSegmentRenderer draws each divergent segment with the existing
Desktop leaves (AsyncImage media + onImageClick, RenderInvoiceCard/RenderCashuCard,
QuotedNoteEmbed, RenderBechSegment, RenderPdfCard/RenderNowhereLinkCard,
RenderSecretEmoji, relay copy).
- DesktopRichText replaces the hand-rolled DesktopRichTextViewer switchboard: it
parses with DesktopCachedRichTextParser, keeps markdown on RenderMarkdown, and
drives the shared core via the two CompositionLocals. NoteCard repointed.
- Delete the old DesktopRichTextViewer + RenderSegment + the duplicate
RenderCustomEmojiSegment (the core now renders emoji); rename the file.
- Add a NowhereLink method to the contract so both platforms keep their
nowhere.ink card (the core no longer flattens it to a plain link); Android
adapter implements it via NowhereLinkCard.
Verified: :commons JVM, :desktopApp, and :amethyst play debug all compile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
Closes the fidelity gap and flips Amethyst's rich text onto the shared core:
- Move the hashtag-icon table (HashtagIcon + checkForHashtagWithIcon) into
commons/ui/richtext (the icons were already in commons); amethyst re-exports
it for existing call sites.
- Add a commons custom-emoji renderer (RenderCustomEmoji + InLineIconRenderer)
built on quartz CustomEmoji.assembleAnnotatedList, mirroring CreateTextWithEmoji.
- Wire both into the commons RichTextViewer core (hashtags now show shared inline
icons; emoji render inline).
- Point amethyst's RichTextViewer at CommonsBackedRichTextViewer (the shared core
is now the production plain-text path; ~55 call sites unchanged) and delete the
now-dead private RenderRegular switchboard. Markdown stays native.
Verified: :commons JVM and :amethyst play debug compile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
Implements the commons RichTextSegmentRenderer with Amethyst's existing
touch-first leaves (ZoomableContentView, ImageGallery, LatexEquation,
BechLink/TagLink, MayBe{Invoice,Withdrawal}/CashuPreview/ClinkOffer,
LoadUrlPreview, relay/concord chips, DisplaySecretEmoji), closing over
AccountViewModel/INav/backgroundColor/callbackUri/canPreview.
Adds CommonsBackedRichTextViewer, a parallel entry that parses with the
existing CachedRichTextParser, keeps markdown native, and drives the
shared commons RichTextViewer core via the two CompositionLocals. Left
parallel to the existing RichTextViewer so the core is exercised against
real leaves without touching the ~55 call sites; flipping the entry over
(and deleting the duplicated switchboard) is the reviewed follow-up.
Verified: :commons JVM target and :amethyst play debug both compile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
Introduces commons/ui/richtext: one cross-platform RichTextViewer that
both the touch (Amethyst Android) and mouse-first (Desktop) front ends
can drive, to replace the two current forks (amethyst RichTextViewer +
DesktopRichTextViewer).
The shared core owns the universal parts (paragraph/RTL/word layout,
plain text, inline custom emoji, hashtags) and delegates the segments
whose *presentation and* call-to-action diverge by platform (media,
equation, quoted event, mention, payment, link preview, relay/invite,
secret message) to a RichTextSegmentRenderer strategy provided via
LocalRichTextSegmentRenderer -- the same CompositionLocal idiom the
codebase already uses for LocalInlineQuoteRenderer. Universal actions
(open url/email/phone, hashtag) go through a small RichTextInteractions
callback bag. A PlainTextSegmentRenderer default keeps the core usable
from previews/tests/headless callers.
Contract + skeleton only; compiles in :commons. No consumer wired yet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
TranslatableRichTextViewer can't move (ML-Kit translation is Android),
but the stack is already layered: translation wraps ExpandableRichText
wraps the 1031-LOC RichTextViewer core. TranslationConfig state is
already in commons and the core's real AccountViewModel surface is 5
members (3 cache reads -> ICacheProvider, toast -> callback, nav ->
callbacks). Move the core to commons/ui/text behind the cache port +
callbacks with a renderEmbeddedNote slot for the NoteCompose recursion;
keep the translation wrapper native and thin. This gates Tier 2 so
nested rich text becomes a direct commons call, not a per-renderer slot.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
Merged origin/main into the branch; a formatting violation in
LocalPreferences.kt (globalSettingsPrefs, added on main) was surfacing in
the PR merge-ref lint check. spotlessApply collapses it to one line
(whitespace only, no behavior change) so CI's lint job passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ
Account already injects `val cache: LocalCache` and LocalCache implements
the commons `ICacheProvider`/`ILocalCache` read ports, but AccountViewModel
lookups bypass account.cache and hit the object singleton directly, and
IAccount doesn't expose the cache. Revise the extraction seam: reads cross
via `IAccount.cache: ICacheProvider` (add it) rather than app-supplied
loader lambdas; reserve lambdas for nav + write/signer actions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
Study of com.vitorpamplona.amethyst.ui.note (214 files / ~48.7k LOC,
types/ = 89 files) for moving the rendering half into commons via a
Render (entry, stays native) -> Display (pure, moves to commons) split.
Documents the canonical entry signature, the AccountViewModel/INav/
R.string/leaf-toolkit dependency surface, the @Composable-slot seam for
the flavor-specific rich-text viewer, a tiered categorization of the 89
type renderers, and a per-event sequencing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
Addresses the four findings from the branch audit:
- Make the kotlinx codec (LimitsKSerializer, the iOS/native incoming path)
as lenient as the Jackson path: mistyped fields degrade to null, non-int
array elements are skipped, and a payload-less ["LIMITS"] frame yields an
empty message instead of throwing. Guard the payload access in
MessageKSerializer and MessageDeserializer likewise.
- Make the Jackson reads (LimitsDeserializer) type-checked so an explicit
JSON null or wrong-typed value stays null ("unspecified — keep previous")
instead of coercing to false/0. Both codecs now behave identically.
- LimitsMessage -> data class, so StateFlow.distinctUntilChanged in
RelayLimitsTracker suppresses no-op emissions when a relay re-advertises
identical limits, and tests get value equality.
- Drop the stale "NIP-22" labels (LIMITS is nostr-protocol/nips#1434, not
NIP-22) from LimitsKSerializer, MessageKSerializer and MessageSerializer.
Tests: added mistyped/null-field and payload-less coverage on both the
Jackson and kotlinx paths, plus a value-equality check. quartz:jvmTest
(RelayWireErgonomicsTest 11, KotlinSerializationMapperTest 57,
RelayLimitsTrackerTest 5) and amethyst play compile green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ
Redesigns the always-on notification control into two switches:
- A global master ("Background notification service") — a persisted, battery-saver
"airplane mode" stored in global preferences that overrides every account. The
Quick Settings tile toggles this; it survives restarts and crashes until re-enabled.
- Per-account participation ("Keep this account active in the background") — the
existing per-account setting, now surfaced as a per-write-account list under the
master in the Notification Settings screen.
The always-on service runs while the master is on AND at least one writable account
participates; turning the master off tears down every layer for all accounts. The
manager reacts to both the master flow and each loaded account's participation flag,
and the restart layers (watchdog/boot/worker) honor the same rule.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKLqvcC1vobUYs3PxL2zWU
Captures what's done (parse, RelayLimitsTracker, cleanup), the audit fixes
to fold in (kotlinx parse leniency to match Jackson on iOS, drop stale
NIP-22 labels, make LimitsMessage a data class), and the remaining roadmap
(client apply helpers, server-side emit, NIP-11 bridge, amy tooling).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ
Cleanup after adding LimitsMessage + the client-side cache:
- Remove the unused experimental LIMITS prototype
(experimental/limits/Limits.kt + LimitProcessor.kt). Its @Serializable
model duplicated LimitsMessage (minus auth_for_read/auth_for_write and
the Message wiring); the processor's clamp/reject logic is superseded by
the server-side LimitsPolicy and will be reincarnated as pure helpers on
LimitsMessage. Both were prototypes with no references (preserved in git
history).
- Rename the client accessory RelayLimits -> RelayLimitsTracker so it no
longer collides on simple name with the relay-server-side
nip01Core.relay.server.policies.RelayLimits (the operator-configured
limits a relay enforces and advertises). Updates AppModules and the test.
No behavior change. quartz:jvmTest (RelayLimitsTrackerTest 5/5) and
amethyst play-flavor compile are green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ
Adds RelayLimits, a passive connection-listener accessory (modeled on
RelayAuthenticator) that caches the latest LIMITS each relay advertises
and publishes it as a Compose-stable StateFlow, so consumers can read a
relay's current rights/limits instead of only observing the raw message.
- RelayLimits: caches LimitsMessage per NormalizedRelayUrl, exposes
limitsFlow (StateFlow), get(url) and snapshot(); connection-scoped
(entry dropped on disconnect so stale limits don't leak).
- LimitsMessage marked @Immutable for Compose stability in the flow map.
- Wired into AppModules next to relayStats (Amethyst.instance.relayLimits).
- Tests: per-relay caching, later-replaces-earlier, independent relays,
drop-on-disconnect, and ignore non-LIMITS messages.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ
Adds support for the relay-to-client LIMITS frame, which advertises the
connection's current rights and limits (can_read/can_write, auth
requirements, max_message_length, max_subscriptions, max_filters,
max_limit, POW, rate limits, required tags, etc.). Relays such as
wss://pipe.imwald.eu/ send it on connect and whenever rights change,
and Amethyst previously logged it as an unsupported message.
- New LimitsMessage model in quartz commonMain with every optional field.
- Jackson decode/encode (LimitsDeserializer + MessageSerializer) and the
kotlinx-serialization path (LimitsKSerializer + MessageKSerializer).
- Logs a summary line in RelayLogger.
- Tests: production payload parse, array/tag fields, empty object, and
round-trip parity between the Jackson and kotlinx codecs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ
Authorization hardening:
- LocalCache only applies a kind 39000-39005 addressable (group metadata,
admins, members, roles, pins) to a group's state when the event is signed by
the relay's own NIP-11 `self` key. Previously any author's 39001 served for a
group id was applied (newest wins), so a stray/malicious user-published admin
list on a lax relay could inject itself as admin in the client's view and
unlock moderation UI. The guard only blocks when `self` is known and differs,
so legitimate groups (and relays whose NIP-11 hasn't loaded) are unaffected.
Moderation events (9000-9010) were already stored-but-not-applied — the client
relies on the relay-republished 39001/39002 — so they need no change.
Invite links:
- The invite dialog now produces the spec's single `naddr1…?invite=<code>`
link for closed groups and copies just that, so one tap joins. It previously
copied the naddr and code as two separate lines, which the new parser (that
reads `?invite=`) could never round-trip.
spotless clean; amethyst compiles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S
On cold boot an AUTH-required kind:10050 DM-inbox relay usually sends its
AUTH challenge before the account's own kind:10050 list has been fetched.
AuthApprovalPolicy.classify reads the trusted (self-approved) relay set
exactly once, so it classifies the user's OWN inbox relay as tier-2 and
surfaces a manual `[Once][Always][Never]` banner. Nothing re-evaluates
that pending decision when the kind:10050 list finally loads, so the user
gets a spurious AUTH prompt for a relay that should have auto-signed.
Extract the pending-approval set into a platform-agnostic
commons/AuthApprovalRequests (add/resolve/cancelAll) and add
autoApproveNowTrusted(): when the DM-inbox set updates, retroactively
settle every pending prompt whose relay is now tier-1 with ONCE — sign
this session, do not persist (trusted by identity, not an explicit grant).
DesktopAuthCoordinator now delegates its pending set to AuthApprovalRequests
and exposes onSelfApprovedRelaysChanged(); Main.kt drives it from
DesktopAccountRelays.dmRelayList (the account's kind:10050 StateFlow).
AuthApprovalRequestsTest reproduces the cold-boot race (red before the fix)
and covers the resolve / cancelAll paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bugs:
- Metadata edit could re-root a subgroup or drop its children on a load race:
the edit ViewModel snapshotted parent/children at prefill and overrode the
Account-level live-read defaults. Children are no longer snapshotted (Account
reads the live child list at save time), and the parent is only overridden
when the user actually re-parents (parentTouched) — a plain rename can't
re-root or orphan children anymore, even if metadata hadn't loaded yet.
- Parent selector card cached a null channel via remember() and never
refreshed, so the parent's name/picture never loaded and the warm-up never
mounted. Now get-or-create + warm + observe the metadata flow.
- previousEventRefs could let a note with an unresolved author slip past the
self-exclusion and reference the sender's own event. Now requires a resolved
author.
- Assigning a relay-defined role replaced the member's whole role set while the
menu implied additive; now keeps existing roles (entry.roles + role.name).
- GroupNAddrInvite now also accepts a bare `invite=<code>` remainder if the `?`
is stripped upstream (+ test).
Performance:
- Subgroups bar mounted a full warm-up (metadata + content) subscription per
child chip — up to ~21 relay subscriptions per open group. Replaced with one
relay-directory subscription; chips read from cache.
- Parent picker recomputed the whole candidate scan on every recomposition
(each search keystroke) via a produceState initial-value argument; the scan
now lives only in the producer with a cheap empty initial.
spotless clean; quartz tests green; amethyst compiles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S
Lets an admin nest a group under a parent (or promote it back to top-level)
from the create/edit metadata form, completing the subgroup feature's authoring
side (viewing/navigation already shipped).
- RelayGroupMetadataViewModel: editable parentGroupId + preserved children,
seeded from the group's metadata and passed through on save. NIP-29 requires a
kind-9002 to re-list every child, so children are carried untouched.
- Account.createRelayGroup: optional parent so a group can be created nested.
- RelayGroupParentPicker: a hero selector card (gradient badge, parent avatar or
a top-level/home glyph) that opens a modal bottom-sheet picker — search field,
a "no parent (top-level)" choice, and the relay's genuine groups with avatars,
member counts and an animated selection mark. Excludes the group itself and its
own descendants so a cycle can't be formed, and streams the relay directory
while open so candidates fill in live.
spotless clean; amethyst compiles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S
Follows up the subgroup protocol work with four NIP-29 compliance/UX gaps.
previous timeline references (spec §Timeline references):
- RelayGroupChannel.previousEventRefs(): first-8-char id prefixes of the most
recent events seen from the host relay, excluding the sender's own, capped at
the spec's 50-event window. Only draws from events actually received in the
channel so the host relay is known to have them.
- Populate the `previous` tag on all outgoing group events: kind-9 chat and
replies, kind-1111 minichat comments, kind-11 threads, and group replies.
Custom roles (kind 39003):
- Route SupportedRolesEvent onto the channel (LocalCache.consume + a
RelayGroupChannel.supportedRoles field) instead of only storing it.
- Members screen: when the relay advertises a role set, offer those roles when
assigning (admins), and show each member's real relay-assigned role label
instead of collapsing everything to admin/moderator. Falls back to the
built-in admin/moderator shortcuts when no 39003 is published.
Subgroup navigation (spec §Subgroups):
- RelayGroupSubgroupsBar: a self-hiding bar under the pinned bar showing a
breadcrumb up to the parent group and chips for the child subgroups (in the
relay's `child` order), each opening that group on the same host relay.
naddr invite codes (spec §Group identifier):
- GroupNAddrInvite parses the `naddr1…?invite=<code>` suffix; both the tap
handler (ClickableRoute) and the deep-link handler (MainActivity) now feed it
into the kind-9021 join request so a shared invite naddr auto-joins.
Tests for the naddr invite parser. spotless clean; quartz tests green;
amethyst compiles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S
GalleryListEvent (kind 10011, @Deprecated "Replaced by NIP-68") was
unreachable: EventFactory routes kind 10011 to ExternalIdentitiesEvent,
and no other code referenced the class. It also shadowed NIP-51's
"Favorite follow sets" kind. Removing the dead class; the live profile
gallery entry (ProfileGalleryEntryEvent) and its builders are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017giudm3gXumsxZmd3uMQc8
NIP-51's kind:10000 mute list defines four entry types — `p` (pubkeys),
`word`, `e` (threads) and `t` (hashtags). Quartz parsed only the first
three, so `t` hashtag mutes written by other clients were silently
dropped: uncounted, invisible, and never applied to filtering.
Quartz:
- Add HashtagTag (`"t"`) implementing the MuteTag sealed interface, and
register it in MuteTag.parse/isTagged so it round-trips like the other
entry types.
- Add mutedHashtags()/mutedHashtagIds() TagArray helpers.
Filtering (commons):
- Add hiddenHashtags to LiveHiddenUsers plus isHashtagHidden(), and hide
notes carrying a muted hashtag in Note.isHiddenFor() (exact, case-
insensitive `t`-tag match — distinct from the existing substring word
scan).
Amethyst:
- Aggregate HashtagTag entries from the mute/block lists in
HiddenUsersState.
- MuteListState.hideHashtag/showHashtag + Account and AccountViewModel
wrappers, and observeUserIsMutingHashtag.
- Surface a Mute/Unmute hashtag action in the hashtag screen's options
overflow menu.
Tests: HashtagTagTest (parse/round-trip/MuteTag dispatch) and
NoteIsHiddenForTest cases for muted-hashtag hiding.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017giudm3gXumsxZmd3uMQc8
Implements the NIP-29 Subgroups feature merged upstream: groups can now be
organized into a parent/child tree, scoped per host relay.
Quartz:
- Add `parent`/`child` tag classes and TagArray (builder) helpers.
- GroupMetadataEvent (39000): parent()/children()/isRoot() accessors and
build params.
- EditMetadataEvent (9002): parent()/children() accessors and build params
(a 9002 re-carries the full child list, per spec, or the relay rejects it).
- SubgroupTree: assembles a relay's flat 39000 set into the hierarchy —
structure follows each group's parent tag, sibling order follows the
parent's child-tag order, orphans surface as roots, and malformed cycles
are broken rather than looping.
- NIP-11: advertise/detect subgroup support via `nip29: { subgroups: true }`,
with a `subgroups()` builder DSL helper.
- Tests for tag round-trips, tree assembly, ordering, orphans and cycles,
plus NIP-11 serialization.
Amethyst:
- RelayGroupChannel: parentGroupId()/childGroupIds()/isSubgroup() reading the
latest metadata.
- Account.editRelayGroupMetadata: preserve the group's current parent and full
children list on a plain metadata edit so an admin renaming a subgroup no
longer detaches it (or gets rejected for dropping children).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S
The previous regression test drove PreferencesAuthApprovalStore against
the real java.util.prefs.Preferences.userRoot() backing store, which
threw IllegalArgumentException on the macOS CI runner (its preferences
backend rejects operations the Linux one accepts).
Extract the key derivation into a pure `authApprovalPreferenceKey`
function and assert the invariant that actually matters — every derived
key stays within Preferences.MAX_KEY_LENGTH, which is exactly the
condition put() enforces. No OS I/O, so it runs deterministically on
every platform.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aJPf1AsPTdzdMYR6pZ4e2
The desktop AUTH banner ("<relay> requires authentication to deliver
this message") kept re-appearing on every challenge even after the user
clicked Always or Never.
Root cause: PreferencesAuthApprovalStore used the raw relay URL as the
java.util.prefs.Preferences key. Preferences caps keys at
MAX_KEY_LENGTH (80 chars) and throws IllegalArgumentException from put()
for anything longer. Outbox-proxy relay URLs that embed an npub and a
query string routinely exceed that (e.g.
wss://filter.nostr.wine/npub1...?broadcast=true is 102 chars), so
setScope threw. RelayAuthenticator swallows the exception, so the
ALWAYS/BLOCKED grant was silently never persisted and the relay
re-prompted on the next AUTH challenge.
Fold any relay URL over MAX_KEY_LENGTH into a bounded sha256:-prefixed
64-char hex key. Short URLs are still stored verbatim so existing grants
keep working.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aJPf1AsPTdzdMYR6pZ4e2
Follow-up making the invite redeemer match the CORD-05 §2 spec for the
addressable invite coordinate (33301, link_signer, d=""):
- vsk=6 → live bundle (open with the link token)
- vsk=9 → revocation tombstone: the newest event wins, so a tombstone buries
even a stale, still-openable copy on another relay ("a fetcher finds the
grave instead of keys"). Amethyst previously never checked for this, so a
revoked link failed generically.
- anything else present (e.g. a mis-posted registry vsk=8, the shape of the
relayop.xyz link that hung) → unreadable
- nothing on any relay → absent
New pure `ConcordInviteBundle.classify(wraps, token): InviteBundleStatus` in
quartz (next to parse/validate), wrapped by `ConcordActions.classifyInvite`,
and mapped by `Account.joinConcordViaInvite` to the `ConcordInviteResult`
cases — including a new `Revoked` outcome with its own message and no futile
retry. Crypto is unchanged and already matches the spec
(hkdf(token,'concord/invite-key') → NIP-44 → snake_case CommunityInvite).
Adds ConcordInviteClassifyTest covering live / revoked (order-independent) /
unreadable / absent, plus the real relayop.xyz vsk=8 event → Unreadable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KngFNwrQDLYa9QW1f5RRD