Commit Graph
1184 Commits
Author SHA1 Message Date
nrobi144andClaude Opus 4.8 49d0518db1 feat(desktop): moderation follow-ups — thread/profile enforcement, sensitive toggle, mgmt screens
Completes the deferred items from the moderation & safety plan:
- Thread + Profile feeds now pass the real hidden lambda (via LocalDesktopIAccount),
  so mutes hide replies-in-thread and profile-tab notes live. Thread root stays shown.
- 'Always show sensitive content' toggle: new PreferencesSensitiveContentSettings
  (commons/jvmMain, java.util.prefs) backs DesktopIAccount.showSensitiveContentSetting
  (null=blur / true=show, never false) + setAlwaysShowSensitive; unit-tested.
- ModerationSettingsSection in the Content Filters settings: the toggle + management
  lists for muted users (unmute), hidden words (add/remove), muted threads (unmute),
  driven by the live hidden-users flow so removing an entry un-hides immediately.
- Profile header overflow (MoreVert): Mute/Unmute + Report… (reuses ReportNoteDialog)
  for other users on writeable accounts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:04:23 +03:00
nrobi144andClaude Opus 4.8 9090dfe82c fix(desktop): enforce mute/block on feeds (was a silent no-op)
Desktop DesktopIAccount.isHidden()/isAcceptable() were stubs (false / deletion-only)
and DesktopFeedFilters never consulted them, so muting/blocking a user did nothing.

- Add DesktopHiddenUsersState: assembles the kind-10000 mute list (users, hidden
  words, muted threads) + kind-30000 block list into a live StateFlow<LiveHiddenUsers>,
  decrypting the private section via the shared Mute/PeopleListDecryptionCache.
- Wire DesktopIAccount.isHidden/isAcceptable + the content-filter fields to it.
- Chain !note.isHiddenFor(...) into every note-rendering DesktopFeedFilter
  (global/following/custom/profile/reads/search/notification + thread replies).
- DesktopFeedViewModel re-invalidates the feed when the choices change, so mutes
  hide live without a restart.
- Subscribe to the account's kind-10000 mute list in Main.kt so it hydrates.

Reuses the shared commons LiveHiddenUsers + Note.isHiddenFor; the chatroom DM list
already called isAcceptable, so DMs now enforce mutes too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 10:01:30 +03:00
Vitor PamplonaandGitHub 99f28ee1ca Merge pull request #3593 from vitorpamplona/claude/nip-51-compliance-review-vwhyvn
Add hashtag muting support via NIP-51
2026-07-15 22:07:00 -04:00
Claude abc6732286 feat: support NIP-51 mute-list hashtag ("t") entries
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
2026-07-16 01:51:48 +00:00
Vitor PamplonaandGitHub 67efac7972 Merge pull request #3592 from vitorpamplona/claude/amethyst-invite-link-hang-w0tagl
CORD-05: Distinguish invite redemption failures for better UX
2026-07-15 21:29:52 -04:00
Claude ae9f4a0def fix(concord): resolve invite coordinate per CORD-05 (honor revocation)
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
2026-07-16 01:24:37 +00:00
Claude 6735c0b7a6 fix(commons): import kotlinx.coroutines.IO for native Dispatchers.IO access
`BlossomServerListState` used `Dispatchers.IO` without importing the
common `kotlinx.coroutines.IO` accessor. On JVM/Android the JVM member
resolved fine, but on Kotlin/Native (iosSimulatorArm64) it resolved to
the internal `Dispatchers.IO` member and failed to compile:

    Cannot access 'val IO: CoroutineDispatcher': it is internal in
    'kotlinx.coroutines.Dispatchers'.

Add the missing `import kotlinx.coroutines.IO`, matching every other
common-source state holder in this module.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtPgFEy9AxoaUT5AqEFBZX
2026-07-16 01:10:31 +00:00
Claude 275df2ae9c fix(desktop): drain kind:10002 back-fill fully and close on EOSE
Audit follow-ups on the DM relay-list work:

- Bug: scheduleOutboxBackfill re-scheduled itself from inside the still-active
  drain job, so the isActive guard made the tail call a no-op — any authors
  past the first 100-author batch in a burst were stranded until another kind:0
  happened to arrive. Drain in a while-loop inside one job instead, and mark it
  @Synchronized so concurrent consume-path callers can't spawn duplicate jobs.
- Perf: the back-fill held each REQ open for a fixed 8s. Use fetchAll, which
  returns on EOSE (bounded by the timeout), and reuses existing infra.
- DmInboxRelayResolver: drop the redundant `+ cachedOutbox` in the phase-2 seed
  (cached outbox is already in phase1Seed, so it was always subtracted back
  out), and bound the phase-2 fallback fetch to 5s so a cold send can't stack
  two full fan-out timeouts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSc3LhGFSF5VZKr9h3qfn3
2026-07-16 00:31:18 +00:00
Claude 2d311c9324 Merge remote-tracking branch 'origin/main' into claude/nip17-dm-relay-desktop-4shxdy
# Conflicts:
#	desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
#	desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt
2026-07-16 00:20:28 +00:00
Claude 931236e719 feat(desktop): back-fill kind:10002 on profile load and read DM inbox from outbox relays
Answers "do we load kind:10050 from each user's outbox write relays?" — now we
do, and we make sure we know where those relays are.

- DmInboxRelayResolver.resolve is now two-phase: query the curated indexers ∪
  the recipient's known write relays (via a new outboxLookup) for 10050/10002;
  if the indexers didn't carry the 10050, read it directly from the write
  relays learned from their kind:10002 — the canonical NIP-65 outbox location.
  Wired on desktop with cachedAdvertisedRelayList(pubkey).writeRelaysNorm().
- Whenever DesktopLocalCache ingests a user's kind:0, it fires
  onProfileMetadataConsumed; the subscriptions coordinator back-fills that
  user's kind:10002 (batched ≤100 authors/REQ, deduped, one REQ at a time) and
  routes it through consume() so it's saved. So learning who a user is now also
  learns where they write.

Tests: DmInboxRelayResolverOutboxTest covers reading 10050 from the recipient's
write relays (indexers only have their 10002) and the cached-outbox seed path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSc3LhGFSF5VZKr9h3qfn3
2026-07-16 00:15:28 +00:00
Vitor PamplonaandGitHub c95270fca3 Merge pull request #3583 from vitorpamplona/claude/blossom-server-loading-sync-n1l3sw
Sync Blossom media server list (kind 10063) across clients
2026-07-15 20:11:48 -04:00
Vitor PamplonaandGitHub 13cbcf41d6 Merge pull request #3586 from vitorpamplona/claude/nip29-message-pinning-elzck9
NIP-29 message pinning: UI bar, moderation events, and relay group integration
2026-07-15 19:56:53 -04:00
Claude b8966049b8 fix: Concord channel rows stuck on "No messages yet" despite having messages
consumeConcordRumor attaches a message row to its ConcordChannel BEFORE
justConsume loads the event (so the note carries its gatherer through the
Messages filter). That means Channel.addNote ran while createdAt() was still
null and never set lastNote — and on reprojects the containsKey guard skips
addNote entirely, so lastNote stayed null forever for every Concord channel.
The list/hub rows read lastNote, so they always showed "No messages yet" (and
activity sorting was a no-op) even after messages had loaded.

Adds Channel.refreshAfterEventLoad(note), called right after justConsume once
the event is present: it picks lastNote when newer and invalidates the notes
flow so previews, unread counts, and ordering recompute. Regression-tested in
commons (ConcordChannelLastNoteTest).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9Uv3h6GhSQVuUTY5Ffm6e
2026-07-15 21:59:25 +00:00
Claude 9ee6f0dbde feat(nip29): message pinning for relay groups
Implements NIP-29 message pinning (nostr-protocol/nips#2379):

Protocol (quartz):
- GroupPinnedEvent (kind 39005): relay-signed pinned-message list, `d`
  group id + ordered `e` ids.
- UpdatePinListEvent (kind 9010): moderator `update-pin-list` write,
  carries the full list so pin/unpin/reorder/clear are one submission.
- Register both in EventFactory; add a pinnedEventIds tag helper.

Model + cache:
- RelayGroupChannel now folds the pin list (pinnedEventIds / isPinned)
  with the same createdAt-supersede guard as the roster.
- LocalCache consumes 39005 into the channel and stores the 9010 write;
  39005 added to the group's metadata REQ filter so pins load.

Publish path:
- Account.pin/unpin/updateRelayGroupPins + AccountViewModel wrappers.

UI (non-intrusive, self-hiding):
- Collapsed pinned-message bar under the top bar: shows the current pin,
  N-count cycling, tap to jump to the message in-feed (hoisted jump
  request threaded through the shared chat feed view). Renders nothing
  when the group has no pins.
- Moderator-only Pin/Unpin action under "Show more" in the chat message
  bottom drawer, gated on membership.canModerate().
- Small pin glyph on pinned bubbles' footer.

Tests: quartz build/parse round-trip for both kinds; channel pin-fold
supersede/replace/clear semantics.
2026-07-15 21:24:37 +00:00
Claude e322c939cc feat(desktop): load Blossom servers from kind 10063 like mobile
The desktop app read its Blossom media server list only from a local
DesktopPreferences string (defaulting to blossom.primal.net) and never
looked at the user's NIP-B7 BlossomServersEvent (kind 10063) — the same
event the Amethyst mobile app loads via BlossomServerListState. A server
list configured on mobile therefore never showed up on desktop.

Load the list from the network event instead, mirroring the existing
desktop NIP-65 flow:

- Add a shared, platform-agnostic BlossomServerListState in commons that
  reads the kind-10063 addressable event from ICacheProvider and exposes
  a StateFlow<List<String>> plus a save helper.
- Store incoming kind-10063 events in DesktopLocalCache.route()
  (consumeBlossomServerList, newest-per-author wins).
- Instantiate blossomServerList on DesktopIAccount and subscribe to
  kind 10063 in the account-config bootstrap subscription.
- Mirror the loaded network list into DesktopPreferences so the upload
  path and cold start reflect it; the network event stays authoritative.
- Feed the media-server settings screen from the network list and, on
  edit, sign+broadcast a new kind-10063 event so changes sync to every
  Amethyst client (writeable accounts only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2
2026-07-15 20:56:57 +00:00
Claude 56e3ab5b8b feat(concord): stamp the wrap's seen-on relays onto the decrypted rumor
Like NIP-17's addRelayToNoteAndInners, propagate the relays a Concord plane
wrap was seen on down to its inner rumor, so a received Concord/Armada
message shows the relays it actually came from — not just the channel's
configured relay set.

The wrap note already carries its seen-on relays (added by consumeRegularEvent
before the gift-wrap handler runs), so GiftWrapEventHandler hands them to
concordSessions.ingest, which threads them through the session/registry to the
rumor sink; LocalCache.consumeConcordRumor then stamps them onto the rumor note
after justConsume. Local-echo sends and buffer re-projections pass no relays
(default empty) since there's no per-wrap relay to attribute there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
2026-07-15 20:00:47 +00:00
Claude 7b93acee0c Merge remote-tracking branch 'origin/main' into claude/modernize-chat-rendering-kigcsh 2026-07-15 15:14:21 +00:00
Claude 7506ca599e fix: resolve Kotlin compiler warnings in commons and amethyst
- PoWPublishQueue: use non-deprecated PersistentMap.putting()/removing()
- NappletBrokerTest: drop cast that can never succeed after assertIs
- PrivacyLockStateTest: remove redundant !! (smart-cast already non-null)
- MinichatScreen: drop unnecessary !! on smart-cast non-null Strings
- Concord screens: remove unnecessary safe calls on non-null ChannelEntity
  and ResponseBody, and the now-dead elvis fallbacks

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Myhus2x1c3BSWCtjenSmkf
2026-07-15 14:16:35 +00:00
Vitor PamplonaandGitHub 18e6e83a3a Merge pull request #3552 from nrobi144/feat/desktop-note-scheduling
feat(desktop): note scheduling + NIP-37 draft sync (shared scheduling in commons)
2026-07-15 09:20:03 -04:00
Vitor PamplonaandGitHub 7680dfbce6 Merge pull request #3568 from vitorpamplona/claude/marmot-group-icons-tlcwa1
Add MIP-01 v2 group avatar encryption and upload support
2026-07-15 08:14:25 -04:00
nrobi144andClaude Opus 4.8 3f56d177d8 feat(desktop): note scheduling + NIP-37 opt-in draft sync
Adds note scheduling and NIP-37 opt-in encrypted draft sync to Amethyst
Desktop, and extracts the existing Android scheduled-post code into
`commons` so both platforms (and PowJobRestorer) share one implementation.

- Compose → clock icon → date/time picker (presets + exact-minute); the
  note is pre-signed and stored locally, then published at its time.
- Publishes while the app is open (45s in-app tick + launch catch-up) AND
  while fully closed: an OS job (launchd / schtasks / systemd, registered
  only while the queue is non-empty) relaunches the binary in a headless,
  key-free `--publish-scheduled` mode that opens a websocket and pushes the
  pre-signed bytes.
- A "Scheduled" deck destination (tabs Scheduled / Drafts / Articles):
  status, cancel, publish-now, edit (cancel + reopen prefilled).
- Drafts: save-as-draft with a default-OFF "Sync across devices
  (encrypted)" toggle publishing a NIP-37 DraftWrapEvent (kind 31234,
  NIP-44 to self); drafts sync down on a fresh device.

Extraction / de-dup: ScheduledPost → commons/commonMain; ScheduledPostStore
+ ScheduledPostPublisher → commons/jvmAndroid (Jackson/java.io.File are
gate-forbidden in commonMain). The commons store is a strict superset of
upstream's parallel Android store (account-scoped claim, CLAIM_TTL crash
recovery, PUBLISHING-only status guards, reload-before-claim); upstream's
new ScheduledPostWorkGate gating is adopted to drive it. Single-writer file
lock + reload-before-claim so the in-app timer and headless process never
double-publish. Store file 0600, dir 0700.

macOS verified on the packaged app-image (compose+schedule, in-app publish,
app-closed launchd firing, Scheduled screen, NIP-37 draft round-trip).
Windows/Linux OS-integration authored but untested; headless has no Tor
routing yet — both documented in the PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:26:12 +03:00
Claude ce727351b8 Merge remote-tracking branch 'origin/main' into claude/modernize-chat-rendering-kigcsh
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt
2026-07-15 04:39:47 +00:00
Claude a47fd206e6 Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt
#	cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt
2026-07-15 02:12:18 +00:00
Vitor PamplonaandClaude Opus 4.8 4ec44ad241 feat(concord): harvest full member roster from bounded channel history
The members roster was a fraction of the real membership (e.g. ~13 vs ~44 on
Armada). Concord membership includes every "observed author" (CORD-02 §5 — anyone
seen publishing), but the live channel subs only carry the recent tail the relay
serves, so most members — who posted outside that tail and never sent a Guestbook
Join — never appeared.

Add ConcordMemberHarvest: a headless, run-once background sweep mounted by the
members screen that pages every folded channel's history back to a bounded window
(90 days — tunable; bounds the data pulled onto the device, per the "how far back"
limit) in one pooled `fetchAllPagesFromPool`. The wraps ride the app's normal ingest
(global CacheClientConnector → concordSessions.ingest), which folds each author into
`observedAuthors`, so the roster fills in with no extra plumbing. AUTH is free — the
channel stream keys are already registered for these relays. `beginMemberHarvest()`
gates it to once per community.

Prerequisite fix: `ConcordCommunitySession.ingest` re-decrypted a channel's WHOLE
wrap buffer on every incoming message (reprojectChannel), which is O(n²) in the
message count — fine for a ~50-wrap live tail but fatal for a history sweep. Split
it: a message now projects only its own wrap (O(1)); the re-decrypt-all path stays
for a re-fold (where channel keys can change). This also speeds the live path.
`ConcordCommunitySessionTest` now asserts the one-wrap-per-message projection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 21:58:48 -04:00
Claude 3950323ba3 fix(concord): audit fixes — memory, folding, concurrency, notifications, UI
Address the deep-audit findings across the new Concord code:

- H1: stop persisting kind-21059 ephemeral typing wraps as durable notes.
  EphemeralGiftWrapEvent extends GiftWrapEvent, so every heartbeat was
  stored forever; drop it once the session has ingested it.
- H2: follow()/unfollow() now read the offline backup (entriesWithBackup),
  so a join racing the async backup load can no longer wipe the joined list.
- M1 (banlist): fold to the head (honors a chained unban) then union in
  authorized editions that aren't ancestors of the head — concurrent bans
  are healed without resurrecting an on-chain unban (CORD-06 down-only).
- M2: reproject only the newly-arrived channel wrap incrementally instead
  of re-decrypting the whole buffer per message (was O(n^2)); refold only
  projects newly-folded channels.
- M3: cancel a session's old state-watcher before replacing it on a
  Refounding rebuild (was a coroutine + session leak per rekey).
- M4: publish typing/state/members/observed-authors under the lock and
  make revision/observedAuthors updates atomic; clamp future-dated typing.
- M5: notification Concord bypass now requires the community to be one this
  account has currently joined (mirrors the Marmot guard).
- M6: Concord chat honors the "Messages in notifications" toggle.
- L1: carry NIP-30 emoji tags on minichat replies, image captions and
  custom-emoji reactions.
- C1: make the composer VM init() idempotent so recomposition can't wipe a
  picked image or an open suggestion list.
- C2/C3: ConcordHome channel rows and unread badges react to the channel's
  own notes flow instead of the global revision (no stale rows / flicker).
- C4: try/finally around mint-invite / create / save so a thrown call can't
  strand the button disabled.
- C5: gate the typing ticker on active heartbeats so an idle channel stops
  waking a 2s loop.

Adds regression tests for concurrent-ban union-heal and unauthorized bans.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-15 00:37:13 +00:00
Claude de2e71dad0 fix(marmot): match mdk/whitenoise MIP-01 v2 group-image scheme for interop
Verified against the mdk-core revision whitenoise-rs pins (marmot-protocol/mdk
@e8cd584): its NostrGroupDataExtension parser consumes name/description/admins/
relays/image_hash/image_key/image_nonce/image_upload_key and rejects ANY trailing
bytes at a known version, and its extension/group_image.rs fully implements avatar
encryption. The previous "canonical raw-key + media_type" approach both (a) added a
trailing media_type field that mdk rejects — breaking the whole group for whitenoise
members — and (b) used a key scheme mdk can't decrypt.

Re-implement to mdk's exact MIP-01 v2 scheme so avatars interoperate byte-for-byte:
- image_key / image_upload_key are HKDF seeds (reusing Mip01ImageCrypto's
  mip01-image-encryption-v2 / mip01-blossom-upload-v2 labels; HKDF-SHA256 with empty
  salt == mdk's Hkdf::new(None, seed)). AEAD key derived from the seed.
- ChaCha20-Poly1305, 12-byte nonce, EMPTY AAD, image_hash = SHA-256(ciphertext).
- Decrypt tries v2 (HKDF) then falls back to v1 (raw key), exactly like mdk.
- Remove media_type from the wire entirely (and from the model/cipher/uploader), so a
  v2 image extension ends at image_upload_key with zero trailing bytes. The plaintext
  MIME isn't stored; the display path lets Coil sniff the format.
- Derive the Blossom upload keypair from image_upload_key instead of storing a raw key.

Adds a regression test that reproduces mdk's v1/v2 field consumption and asserts a
v2 image extension has no trailing bytes, plus a test pinning the HKDF-seed + empty-AAD
scheme so future drift from mdk is caught.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL3GXW1fmHa3xWfQjLLqfp
2026-07-15 00:32:32 +00:00
Claude c482af0578 feat(concord): custom-emoji autocomplete in the channel composer
Wire the shared NIP-30 custom-emoji picker into the Concord composer like the
@-mention flow: typing `:shortcode:` opens ShowEmojiSuggestionList (backed by
EmojiSuggestionState(account.emoji)); WatchAndLoadMyEmojiList loads the user's
packs. On send, account.emoji.findEmojiTags(text) attaches the NIP-30 emoji tags
to the kind-9 rumor (plain message + inline reply), so recipients render the
custom image inline via the shared chat renderer. Image uploads in messages,
icon and banner were already wired.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-14 23:36:50 +00:00
Claude 7cd2be1c74 feat(concord): channel management + community banner & relay editing
Two ways to update a Concord community/its channels that were missing:

Channels (net-new): ConcordModeration.defineChannel writes a ChannelEntity
control edition (create/rename/delete via version chaining); Account gains
createConcordChannel/renameConcordChannel/deleteConcordChannel. The channel-list
screen gets a create FAB and a per-row rename/delete menu, all gated on
MANAGE_CHANNELS (the same predicate the fold enforces).

Community metadata: the edit screen now edits the banner (encrypted ImagePointer
upload via the shared banner hero, reusing ConcordImageUploader) and the relay
set (add/remove chips + RelayUrlEditField). Also fixes editConcordMetadata
silently dropping the banner on every save (it now round-trips it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-14 23:36:50 +00:00
Claude 339d7c10e7 feat: Marmot group icons — canonical encryption, feed display, metadata editing
Add first-class support for Marmot (MLS-over-Nostr) group avatars.

Protocol (quartz):
- Implement the canonical `marmot-group-image-v1` scheme: raw ChaCha20-Poly1305
  key + 12-byte nonce, AAD = "marmot-group-image-v1" || 0x00 || media_type,
  image_hash = SHA-256(ciphertext). MarmotGroupImageEncryption emits canonical
  and decrypts both canonical and the deprecated MIP-01 HKDF-seed scheme.
- Add the `image_media_type` field to MarmotGroupData as a trailing TLS field
  (older readers ignore it; disappearing_message_secs stays positionally
  unambiguous). Add withImage/withoutImage helpers.
- MarmotGroupImageCipher (NostrCipher) drives both encrypted upload and
  transparent decrypt-on-download.

Model/manager (commons):
- MarmotGroupChatroom exposes an `image` StateFlow; MarmotManager.syncMetadataTo
  populates it from the group metadata.

Android:
- Show the decrypted group icon in the Messages feed; when a group has no image,
  fall back to the NIP-11 icon of one of its relays (fetched on cache miss).
- Group metadata editing gains an icon picker (add/change/remove) in both the
  create and edit screens; create also gains a description field. Icons are
  encrypted and uploaded to Blossom via UploadOrchestrator, signed with a fresh
  per-image keypair stored as image_upload_key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL3GXW1fmHa3xWfQjLLqfp
2026-07-14 23:17:34 +00:00
Vitor PamplonaandClaude Opus 4.8 d84555a27b fix(concord): real role names + full member roster (CORD-02 §5 / CORD-04)
Two roster gaps against the reference client (Armada):

1. Moderators showed as "Admin" and role definitions were often empty. The
   displayed roles came from ConcordCommunityState.roles, which was built from the
   RAW structural fold heads — so a rogue higher-version edition on a role's
   coordinate (e.g. marking the Admin role deleted) corrupted or emptied the roster,
   and even when present the UI collapsed every role-holder to a single "Admin"
   badge. Now state.roles comes from the authority-gated resolver
   (AuthorityResolver.roles(), exposed alongside rolesFor()), and ConcordMembersScreen
   renders each member's actual most-privileged role name (Admin / Moderator / custom).

2. Member count was a fraction of the real one (e.g. 10 vs ~44). CORD-02 §5: "an
   author seen publishing is observably present, auto-included even if their Join
   never arrived." The roster only counted Guestbook joiners + the privileged roster,
   omitting the bulk of members who never post a Join. ConcordCommunitySession now
   tracks observedAuthors from every decrypted channel message and folds them into
   allMembers() and the roster.

Also: amy's `concord roles/grant/ban/...` now register the control-plane stream key
before draining (like `channels`/`read`/`send` already do), so the mod verbs aren't
served an empty fold on NIP-42-gated relays — used to ground-truth the resolved roles.

Verified via amy against live Soapbox: `concord roles` now returns Admin (pos 1) and
Moderator (pos 2) instead of []. quartz + commons concord suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 19:13:47 -04:00
Claude bf2b283cdc fix: keep NIP-29 group reactions in the group so likes notify
A "like" on a group message was built as a plain NIP-25 reaction — `e`
(message) + `p` (author) + `k` — with no `h` tag. The recipient's only
notification query that reaches the group's host relay,
filterGroupNotificationsToPubkey, is scoped `#p`=them AND `#h`=their
groups (kind 7 is already in GroupNotificationKinds), so a like with no
`h` tag is never matched there. It would only surface if NIP-65 routing
happened to drop it on one of the recipient's inbox relays — never for a
host-relay-only group — so likes on group messages effectively never
notified.

Copy the target's `h` tag onto public reactions to group-scoped events,
mirroring how kind-9 replies carry it. ReactionEvent.build gains an
`initializer` (the API GroupScope's KDoc already documented); ReactionAction
applies the group `h` tag for both the tracked and fire-and-forget paths.
The like now lands on the host relay in-group and the existing kind-7
`#p`+`#h` query picks it up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VxpT7J4xt37EF5yJDw1htK
2026-07-14 23:06:18 +00:00
Claude e734d6400c feat(concord): send & receive encrypted image messages (Armada-compatible)
Concord channel messages can now carry images, wire-identical to Soapbox
Armada's `encryptAttachments`: a normal channel-bound kind-9 whose ciphertext
URL is appended to the content and annotated by a NIP-92 `imeta` tag with
`encryption-algorithm aes-gcm`, hex `decryption-key`/`decryption-nonce`, and the
plaintext `ox` hash (no `x`). The blob is AES-256-GCM ciphertext on Blossom, so
the media host and relays only ever see encrypted bytes — the community's E2E
guarantee holds.

Reuses the NIP-17 encrypted-media stack end to end: quartz's imeta tag vocab
and IMetaTagBuilder to build/parse the tag (ChannelChat.imageMessage /
encryptedImageImeta / encryptedImagesOf), the shared UploadOrchestrator
encrypted upload + ChatFileUploadDialog picker on the send side, and the OkHttp
EncryptedBlobInterceptor keyCache on the receive side — registering each
attachment's cipher (keyed by URL) lets the normal feed renderer display the
decrypted image with no shared-render changes. Encryption is mandatory
(no toggle, and a missing cipher fails closed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-14 19:44:56 +00:00
Claude e231b0cf5b Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779 2026-07-14 15:25:40 +00:00
Vitor PamplonaandGitHub 8fc841e50e Merge pull request #3559 from vitorpamplona/claude/notecompose-pow-pill-style-vr6hbt
Unify note-header markers into a pill / quiet-mark design system
2026-07-14 11:19:34 -04:00
Claude 14d28dddad Merge remote-tracking branch 'origin/main' into claude/notecompose-pow-pill-style-vr6hbt
# Conflicts:
#	commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf
2026-07-14 15:00:16 +00:00
Claude 6b6f0fd2ae feat: cap every screen to the reading-column width on wide panes
Capping only the feeds' contentPadding left top bars, list screens,
bookmarks and settings stretched across the whole center pane. Move the
cap up a level: every NavHost destination is wrapped in
CappedScreenContent (600dp, centered) through the shared route builders
in NavigationEffects, so each screen's entire surface — top bar, tabs,
content — shares one reading column, on all ~200 destinations at once.

Opt-outs at registration: Route.Message keeps the full pane for its
two-pane list/conversation split, and Browser/WebApp/NostrApp stay
full-pane so the warm EmbeddedTabLayer surfaces keep lining up.

This supersedes the LocalFeedSidePadding-based capping on Android: the
shell no longer provides side padding (CenterPane is a plain Box again)
and the now-dead overrides in MessagesTwoPane and NotificationSidePanel
are removed. The commons local stays, documented as the padding-based
alternative for hosts like a desktop reading column where gutters
should still scroll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7
2026-07-14 14:51:13 +00:00
Claude c72024578e fix: harden the large-screen shell against runtime window-size changes
Fixes from an adversarially verified audit of the large-screen commit.
The two most serious bugs shared a root cause: the shell was correct at
any fixed size but mishandled the size CHANGING mid-session, which
foldables and multi-window make routine (MainActivity handles those
configChanges without recreation).

Bug fixes:
- Hoist the shell content into movableContentOf so crossing a layout
  tier (fold/unfold, rotate, resize) MOVES the NavHost subtree between
  shells instead of disposing it — screen state (drafts, pager tabs,
  expanded states, warm embedded tabs) now survives.
- DisappearingScaffold snaps bars back to visible when hiding gets
  disabled, so chrome scrolled away before a resize is no longer
  stranded off-screen with no reset path.
- ProfileScreen keeps WindowInsets.navigationBars instead of zeroing
  all content insets; with the bottom bar gone (large screens, and
  pushed entries on phones) content no longer underlaps the system bar.
- New TabReselectCoordinator: AppBottomBar registers each screen's
  re-tap handler even when the bar renders nothing, and the rail routes
  selected-item taps through it — restoring tap-current-tab-scrolls-to-
  top on the rail tier with the screens' existing logic.
- NotificationSidePanel now reuses the screen's SingleNotificationsBody
  (parameterized by scroll-state key), which restores WatchScrollToTop —
  previously the panel stranded scrolltoTopPending=true on the shared
  feed state, suppressing later send-to-top requests — and the inbox-
  relay warning header; it also honors split notifications by showing
  the Following feed when that setting is on.
- Entering the permanent-drawer tier snaps a stale Open drawerState to
  Closed, so returning to a modal tier no longer pops the drawer
  uninvited.
- MessagesTwoPane keys its TwoPane strategy on the width size class so
  the split fraction updates when the pane crosses 840dp in place.
- The drawer status editor calls onDone() after send/delete, so it can
  collapse back to the read-only bar in the docked drawer (and no
  longer waits for a drawer close in the modal one).
- The landscape auto-close drawer effect's inverted condition
  (close-only-when-already-closed, a pre-existing no-op) now closes an
  open drawer as intended.

Structure and performance:
- INav.isDrawerDocked models docked-ness explicitly: Nav.openDrawer()
  no-ops while docked, and consumers stop inferring from a DrawerState
  that never transitions.
- zonedDrawerSwipeIfModal wraps the edge-swipe modifier with the docked
  check so call sites can't forget it; TopBarNavigationIcon centralizes
  the back-arrow/avatar-or-nothing leading slot.
- The rail reuses AppBottomBar's entry icons (NotifiableIcon,
  FavoriteEntryIcon, rememberFavoriteIconModel) instead of duplicating
  them.
- MessagesScreen derives its pane size class via
  WindowSizeClass.calculateFromSize instead of restating the 600/840
  breakpoints.
- rememberFeedContentPadding folds the scaffold, baseline, and side
  paddings into one remember slot; the shell quantizes the feed side
  padding to 8dp steps so continuous resizes don't invalidate every
  feed per pixel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7
2026-07-14 14:51:13 +00:00
Claude 8a34ae692f feat: adapt the app shell to large screens
Three layout tiers driven by the window width size class, published once
through LocalScreenLayout (ScreenLayout.kt):

- Compact (phones): unchanged — bottom bar + modal drawer.
- Medium (portrait tablets, unfolded foldables): the bottom bar is replaced
  by a left NavigationRail built from the same user-configured
  BottomBarEntry list (customization, pinned favorites and new-item dots
  carry over); the drawer stays modal behind the rail's avatar button.
- Expanded (landscape tablets, desktop windows): the drawer is permanently
  docked on the left (no ModalNavigationDrawer), and on windows >= 1200dp a
  docked notification panel renders the notifications card feed on the
  right, sharing last-read marking with the full screen. The panel hides
  while the Notifications screen itself is open.

Large screens also pin the chrome: DisappearingScaffold stops hiding the
top/bottom bars on scroll (and stops toggling the OS status bar), and
AppBottomBar renders nothing everywhere.

Feed content width is capped at 600dp inside wide center panes:
the shell measures the center pane and provides
(paneWidth - 600dp) / 2 via LocalFeedSidePadding (commons), which
rememberFeedContentPadding merges into every feed's contentPadding — the
scroll surface stays full-width so pull-to-refresh and edge scrolling keep
working. Panes that manage their own width (Messages two-pane, the
notification panel) override it back to 0.

Screen sweep: Messages now picks single/two-pane from its actual pane
width instead of the window size class; the Home/Messages pagers only
attach the drawer edge-swipe when a modal drawer exists; top-bar avatar
drawer-openers hide on large screens; FABs keep their bottom spacing
without the bar; the status editor in the drawer no longer cancels editing
when the drawer is permanent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7
2026-07-14 14:50:29 +00:00
Claude 500de62841 fix(ui): explain pending OTS on tap, render the stamp glyph and pill icons larger
Tapping the pending OTS pill now shows a toast explaining that the
attestation is waiting to be stamped into the Bitcoin blockchain (new
ots_info_pending_description). The OpenTimestamps glyph gets a
near-full-em content box — its fine outline read much lighter than
Material's solid shapes at the standard 80..880 bounds — and HeaderPill
icons go from 11dp to 13dp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-14 14:15:56 +00:00
Claude 568aa9b005 fix(ui): use middle ellipsis in HeaderPill labels
Truncated pill labels (capped city names, relay hosts) keep their
distinguishing endings, matching the codebase convention for URLs and
relay links.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-14 13:49:08 +00:00
Claude 811d9cf802 feat(ui): icon-only fork mark, pencil edit mark, tighter expiration and location pills
- The fork marker drops "Forked from <name>" for a bare fork-right icon
  (new MaterialSymbols.ForkRight, U+EBAC); tapping still opens the
  original version. Font regenerated via subset.sh, which also re-baked
  the custom OpenTimestamps glyph, proving the custom-glyph pipeline
  survives regeneration.
- The edited mark becomes a pencil: bare pencil for the latest edit,
  pencil + "#2"/"original" only while cycling versions on tap.
- The expiration pill clamps beyond one year to "1y+" instead of
  switching to a full date.
- The location pill caps at 110dp and ellipsizes, so unbounded city
  names cannot squeeze the author's name out of the row.
- Remove the now-unused existed_since string from all 35 locale files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-14 13:46:12 +00:00
Claude 170bc7c121 fix(ui): restore full-size quiet marks, tighten the dotted timestamp, drop Boosted
Quiet marks go back to the row's regular text size in bold with 16dp
icons; the hashtag/community soft links lose their 12sp override too
(the smaller tier read as too small). A new TimeAgoStyle.DottedTight
renders "• 5m" without the leading space for rows whose spacedBy
already provides the gap, removing the double space before the
timestamp. The OTS pending pill shrinks to the stamp icon plus an
ellipsis (the words move to the content description). The Boosted mark
is removed entirely — from the Android header, the commons component,
and the desktop feed — since the repost context is already visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-14 13:30:38 +00:00
Claude 41ef1d89b3 feat(ui): add the OpenTimestamps logo as a custom glyph and use it in the OTS pill
Bake the official OpenTimestamps stamp logo (traced from
opentimestamps/logo vector.svg; monochrome outline, tinted at render
time like every other glyph) into the Material Symbols subset font at
U+F8F0. New tools/material-symbols-subset/add_custom_glyphs.py converts
the traced SVGs in custom/ into TrueType glyphs and is invoked by
subset.sh after pyftsubset, so font regenerations keep them.

With the logo identifying the pill, drop the verbose "OTS:" prefix:
the pill now reads icon + "2y" (or icon + "Pending", new
R.string.pending).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-14 12:59:30 +00:00
Claude d29ee4e6a6 Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779 2026-07-14 04:27:53 +00:00
Claude 86da29fa78 fix(ui): mute the header pill wash, restore dotted timestamps, tighten menu gap
- HeaderPill's secondary-container background pulled too much attention;
  it now uses a faint onSurface wash (7%) with placeholderText content,
  so pills read as tappable metadata without competing with the note.
- Note-header timestamps go back to the original dotted format at the
  default size; the TimeAgo style/fontSize params are reverted.
- The timestamp + more-options pair renders unspaced again (the dot and
  the button's icon inset provide the separation), fixing the oversized
  gap the row-level spacedBy introduced before the 3-dot menu.
- The header preview now consumes its fabricated events as
  already-verified (they cannot pass id/sig checks, which left every row
  bare), gives the repost a parseable inner event, and fetches the draft
  through its AddressableNote (draft wraps are addressable events).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-14 01:26:33 +00:00
Vitor PamplonaandGitHub 107777369f Merge pull request #3548 from vitorpamplona/claude/user-nicknames-contactcard-mglbz8
Nickname users via NIP-85 contact cards (encrypted petname + private note, custom emojis)
2026-07-13 20:16:09 -04:00
Claude b76b37744e feat: nickname card on the user profile, above the real display name
The profile header no longer replaces the big display name with the petname.
Instead, when the account nicknamed the user (or kept a private note about
them), an outlined card renders above it — petname, divider, private summary —
with the standard Lock private marker in its top-right corner, since both
fields live NIP-44 encrypted in the account's contact card. Tapping the card
opens the shared nickname editor. The profile's own display name stays fully
visible underneath. Feeds, chats and mentions keep rendering the petname
instead of the display name.

To carry the summary into the UI, the commons PetName holder generalizes to
Nickname(petName?, summary?, tags), built when either field exists — so a
note-only card (no petname) now shows on the profile too, while the name
override everywhere else keys strictly off petName.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 23:33:05 +00:00
Claude bed5d93a12 feat(concord): typing indicators (CORD kind 23311)
Publish a kind-23311 typing heartbeat as an ephemeral (21059) stream wrap
on the channel plane, throttled to once every few seconds while composing.
The session folds inbound heartbeats into a per-channel typing map with an
8s freshness window (never echoing the local user), and the channel screen
renders a slim "X is typing…" line above the composer with a ticker so a
typist who stops silently fades out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-13 23:21:01 +00:00
Vitor Pamplona 77300578e9 Merge remote-tracking branch 'upstream/claude/concord-quartz-amethyst-plan-0oy779' into claude/concord-quartz-amethyst-plan-0oy779 2026-07-13 18:29:08 -04:00
Claude b7df458071 feat(concord): true member count from the re-enabled Guestbook plane
The Guestbook membership fold was already written but dormant: I had decoupled
its plane from the shared REQ + AUTH while chasing the empty-channels
regression. The maintainer's `re-authenticate on an auth-required CLOSED` fix
addresses that root cause, and the Guestbook + next-rekey stream keys derive
from the entry alone (so they AUTH on the initial connection, unlike channel
keys that appear only after the Control Plane folds). Re-enable them:

- streamAuthSecretsFor now also signs the aux (Guestbook + next-rekey) stream
  keys; the assembler re-adds auxiliaryPlaneSubs to the plane subscription.
- ConcordCommunitySession.allMembers()/memberCount(): Guestbook joins ∪ owner ∪
  role-holders, minus banned — a best-effort floor (a silent key-holder who
  never posted a join and holds no role is invisible).
- Surface it: the hub community header subtitle shows "N channels · M members",
  and the Members screen lists the Guestbook members alongside owner/admins/banned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-13 22:23:41 +00:00