Every path derived every channel's Chat Plane from the `community_root`,
`private: true` ones included — at six independent call sites (plane
registry, subscription planner, session re-fold, and three send paths).
CORD-03 §1 keys a private channel by the per-member `channel_key` delivered
in the kind-13302 bundle, at that channel's own epoch.
The consequence was not a weakly-enforced ACL but no ACL at all: a private
channel sat on an address every member of the community could derive, so it
was readable *and postable* by people never granted its key. Symmetrically,
a private channel created by a conformant client was addressed somewhere we
never looked, which is the "renders an empty room" symptom. `privateChannel`
had exactly one caller in the tree — a Quartz test — and `private` was
consulted on no send or subscribe path.
Introduce `ConcordChannelPlanner`: one resolver mapping (entry, folded
state) to per-channel plane coordinates — a `write` plane and the `reads`
that span held epochs. Public channels resolve off the root as before;
private channels off the bundle key. Every consumer now reads it, so the
choice of secret is made once rather than re-decided per call site.
The epoch travels with the plane. A private channel's wraps bind to its
channel epoch, so passing the root epoch would have rejected every one of
them on arrival even with the right key — the session's current-plane map
carries the epoch for the same reason.
A private channel we hold no key for is omitted from the channel list and
the Messages inbox, matching the reference client's `channelsView`, and no
write coordinate exists for it, so no send path can fall back to the root.
Reaching one anyway (a stale route) now yields `PostingGate.NoKey` instead
of a composer that would publish nowhere valid.
Migration: "private" channels created by earlier Amethyst builds keep their
history on the root-derived plane, which is no longer read for a private
channel. That history is orphaned deliberately — reading it would mean
continuing to accept traffic on a plane the whole community can write to.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3KXHjmCpUDzrSRyxatbyB
A boolean `canPost()` can hide the composer but cannot say why, so each new
gate degraded into blank space above the keyboard. That is how a banned
Concord member came to get an empty slot and no explanation: the screen's
only explanatory branch tested `dissolved`.
Model the reason instead. `PostingGate` is a sealed interface — Allowed, or
a Blocked subtype (Banned, Dissolved, NoKey, NotAMember, InviteOnly) — and
both channel types now derive it, with `canPost()` defined as
`postingGate() == Allowed` so the answer and the explanation cannot drift.
Adding a gate later is a compile error at the render site until it is given
copy of its own, which is the property the boolean could not express.
One `PostingGateNotice` renders all of them, replacing the Concord-only
dissolved notice and the NIP-29-only join notice; the forum thread list
reuses the same sentence instead of a flat "read-only". The Concord screen
also now collects the channel's metadata flow, so a ban or dissolution
landing mid-session flips the composer instead of waiting for a re-entry.
Only reasons a protocol can produce today are modeled. A Buzz tenant ban
(9040) or timeout (9042) is not among them: the relay decides at publish
time and reports it in an `OK false` no send path surfaces yet, so there is
no local state to derive it from. Nothing is claimed for archived Buzz
channels either — neither the relay contract nor our code refuses writes
there, so gating them would have hidden a composer the relay accepts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3KXHjmCpUDzrSRyxatbyB
Adds a per-status UI-coverage pass over the matrix, graded shown / implied /
invisible, plus four more gaps that are UI-only (the state is tracked
correctly, nothing tells the user).
The notable ones: a ban is never explained to the person banned — in Concord
the composer just vanishes because the explanatory branch only covers
`dissolved`, and on Buzz a tenant ban/timeout is an `OK false` that no send
path surfaces. Stranding after a Refounding is log-only, so it renders as an
ordinary empty community. Buzz tenant moderation (9031/9032/9040-9043) has no
write path at all, and neither does Concord dissolution. Concord's private
channels show a lock and then navigate into a permanently empty room.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3KXHjmCpUDzrSRyxatbyB
Documents the full status matrix for both community protocols, split by who
decides each status: the relay/community side (authoritative, event-backed) or
our own device (local bookkeeping like "Add to Messages").
Covers Buzz/NIP-29 per-channel membership (39001/39002 + the NIP-43 tenant
fallback, the client-only PENDING, NIP-OA virtual membership) and the 39000
flags that change what each status can do; the per-relay/workspace tenant
statuses (owner/admin/member, banned, timed out, archived identity); Concord's
owner-rooted OWNER/ADMIN/MEMBER/BANNED/NONE plus dissolved, epoch, stranded and
the Three Removals; per-channel Concord state; and the device-local layer
(kind-10009 vs kind-13302, invites, dismissals, stars, workspaces, dialect,
view modes, unread), including why remove-from-Messages, ignore and leave are
three different actions.
Also records the gaps the mapping surfaced: private Concord channels are listed
but their plane keys are never derived, RoleScope is decode-only so a
channel-scoped role grants community-wide power, Concord Kick (3309) and NIP-RS
read state are unwired, and Buzz presence (20001) still collides with geohash
presence in EventFactory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3KXHjmCpUDzrSRyxatbyB
Review feedback from PR #3810:
- Gate both new preview call sites behind settings.showUrlPreview(), so a
user on WIFI_ONLY over cellular or NEVER no longer triggers an outbound
OpenGraph request and image download per external-scoped comment. Falls
back to the plain link chip / URL header.
- Wrap the open-in-browser icon in an IconButton so it gets a real touch
target instead of a 14dp one nested inside the card's own clickable,
where a near-miss silently navigated instead.
- Only show that icon when onCardClick is set; without it the icon and the
card tap did the same thing, so existing note-body link cards stay as-is.
- staticCompositionLocalOf for LocalCurrentExternalScope (constant per
screen, avoids read tracking per feed row).
- Inset the loading/error URL header to match the card, and use the
hoisted Size14Modifier.
The leftover inner "a" ring sat in front of the ostrich, making the logo
look like it was behind the circle. Drop the inner-a segments from the @
outline, leaving just the outer ring + tail, and size the ostrich so it
reads clearly as the "a".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
Route the Concord community screen and hub through ShorterTopAppBar (50dp)
instead of the raw Material3 TopAppBar (64dp default), so the top bar — and
its 3-dot overflow menu — sits at the same height as the Buzz/relay-group
community screen, which already uses the shorter bar.
Also drop the recent-posters facepile from the Concord and Buzz channel
rows, and move the last-message time up to the first line (next to the
channel name), leaving the unread-message badge on the second line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017x22okBh6HbiCrJN3zf5xn
RenderNoteRow matched the four concrete video subtypes (VideoNormalEvent 21,
VideoShortEvent 22, VideoHorizontalEvent 34235, VideoVerticalEvent 34236) with
four identical branches. Collapse them into a single `is VideoEvent` branch,
matching how ThreadFeedView's NoteMaster already dispatches, so any future
VideoEvent subtype renders through VideoDisplay automatically instead of
falling through to the text fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCKP4tD2seoDpr3ZcdPJNx
The create screen no longer shows a "Forum channel" toggle — the type is decided
by the community's per-section "+" (Channels vs Forums) and a Buzz channel's
channel_type isn't editable anyway (the relay's 9002 has no such key). The screen
loads with the fixed isForum parameter and creates the right type; its top-bar
title reads "New forum" when isForum, "New channel" otherwise (on Buzz).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
Tapping a note in the thread view toggles the collapse/expand state of
its children. For the user's own drafts, tap now opens the edit-draft
screen (via routeEditDraftTo) so the post can be resumed, matching the
behavior everywhere else a draft is tapped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQCNuxdYkfouNN3ujxm3mR
A channel deleted elsewhere (or before this device saw its metadata) keeps its
kind-44100 membership — the relay never retracts it — but the relay soft-deletes
its 39000, and it does NOT serve a channel_deleted 40099 for an already-deleted
channel, so the discovery fetch can't catch it. Such a channel arrived with no
metadata and was shown optimistically: a bare UUID row with "No messages yet".
The reliable signal is that very metadata absence. BuzzRelayImportViewModel.discover
now drops channels the relay serves no 39000 for — but only when the fetch clearly
worked (some channel returned a 39000); if none did, the read failed (auth/
connectivity) and the whole set is kept rather than blanking the community. A
genuinely new channel whose 39000 is merely slow returns on the next bind once its
metadata is cached, so the prune is live (not persisted) and self-correcting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
The "+" was gated on BuzzCommunityMembership (kind-13534), but that roster is only
fetched by the Members screen — nothing on the channel-list screen subscribes to
it, so isMember read false and the "+" hid even from admins. Match the workspace
overflow menu's approach instead: offer create on any Buzz community and let the
relay reject a non-member's kind-9007 (its own doc: "any member sees them, the
relay only serves the owner/admin ones").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
Kind 1111 comments scoped to a NIP-73 external identifier (e.g. a web
article) rendered as a bare URL with no context. Reuse the existing
OpenGraph preview pipeline so feed rows, expanded threads, the
reply-compose banner, and the URL thread screen all show a rich
image/title/description card instead, and give the URL thread screen
a proper scrolling header instead of a bare title.