Files
amethyst/desktopApp/plans/2026-07-27-feat-desktop-profile-parity-plan.md
Claude f72388a559 refactor: rename packages, helpers and app code after the NIP event renames
Follow-up to the event class renames: the names around those classes now
match too.

Quartz (published API, every old name kept as a @Deprecated alias/forwarder):
- nip51Lists packages moved: followList -> starterPack, hashtagList -> interestList,
  peopleList -> followSet, labeledBookmarkList -> bookmarkSet. The old packages
  keep a Deprecated.kt with typealiases and forwarding extension functions
  (ReplaceWith points at the new package).
- LnZapPrivateEvent -> PrivateZapEvent, LnZapReceiptValidator -> ZapReceiptValidator,
  ChannelListDiff -> PublicChatListDiff, HashtagListDiff -> InterestListDiff.
- Kind 30063 now indexes NIP-82 release notes for search (never NIP-51 content,
  which can hold encrypted private items). searchable-events docs updated.

App code (amethyst, commons, desktopApp, cli; no aliases needed):
- model packages nip51Lists.{hashtagLists,peopleList,labeledBookmarkLists,relayFeeds}
  -> {interestLists,followSets,bookmarkSets,favoriteRelays}.
- State/cache/UI names derived from the old event names: HashtagListState ->
  InterestListState, PeopleListsState -> FollowSetsState, FollowListsState ->
  StarterPacksState, LabeledBookmarkList -> BookmarkSet, RelayFeedListState ->
  FavoriteRelayListState, ContactCardsState -> UserAssertionsState, NIP90* view
  models/renderers -> Dvm*, LnZap* handlers -> Zap*, SealedRumor* -> Seal*, and
  Account.peopleLists/followLists/hashtagList -> followSets/starterPacks/interestList.
- String literals were left untouched, so preference keys and @SerialName values
  stored on disk are unchanged.
- The generic PeopleList UI model (shared by follow sets and starter packs) and the
  EmojiPackSelection route (which shows a kind 30030 pack) keep their names.

Docs: plans, brainstorms, changelogs, skills and READMEs use the new names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VvJ8XpeYqBe8bYT3bJ7UD
2026-09-26 20:56:07 +00:00

23 KiB
Raw Permalink Blame History

title, type, status, date, origin
title type status date origin
Desktop Profile Feature Parity feat active 2026-07-27 docs/brainstorms/2026-07-27-feat-desktop-profile-parity-brainstorm.md

✨ Desktop Profile Feature Parity

Bring the Desktop (desktopApp/) user-profile experience to parity with Amethyst Android (amethyst/), for both viewing and editing.

Origin: grounded in docs/brainstorms/2026-07-27-feat-desktop-profile-parity-brainstorm.md. Carried-forward decisions: core parity first / niche deferred, single feature branch, new action builders → commons, Mutual tab + Add-to-list in core.

Enhancement Summary (deepened 2026-07-27)

Six parallel research/review agents (codebase-grounding, architecture, security, simplicity, flow-gaps, past-learnings) verified this plan against source. Key changes folded in:

  1. CORRECTION — Block list kind was wrong. Block is FollowSetEvent kind 30000, d-tag "mute" — not 30382. Kind 30382 is already NIP-85 UserAssertionEvent (WoT/GrapeRank) in this codebase; using it would collide with the WoT feature.
  2. Don't hand-roll Block. FollowSetEvent.addUser(earlierVersion, isPrivate=true) + BlockPeopleListState + FollowSetDecryptionCache already exist and do a correct, fail-closed decrypt→merge→encrypt round-trip. The new commons builder is a thin adapter; never call create() on an already-existing (replaceable) list — that silently un-blocks everyone.
  3. Prerequisite gap. DesktopLocalCache has no observeEvents(filter) accessor; Followers + Zaps tabs need it. Add it first. (Relays/Following/Bookmarks/Mutual use accessors that already exist.)
  4. Private zaps decrypt only on your own profile, and Desktop's privateZapsDecryptionCache is a null stub — must wire PrivateZapCache(signer), gate on isWriteable(), and decrypt lazily (a NIP-46 bunker does one round-trip per zap).
  5. Enforcement must be unified now: isUserHidden = mute ∪ block through one combined hidden-set across all new tabs (route Block through DesktopIAccount symmetrically with moderation-safety's updateMuteList). Shared HiddenUsersState extraction = follow-up.
  6. New flow rules: actor×subject matrix (own/other/logged-out), self-action guards (no self-block footgun), per-tab loading/empty/partial states, AlertDialog-subscription gotcha for the pop-up modals.

Sub-plans (per-phase, deepened 2026-07-27)

Phases 2 and 3 are unpacked in dedicated grounded sub-plans (Phase 1/4 need no further detail):

  • Phase 2 — Tabs: desktopApp/plans/2026-07-27-feat-desktop-profile-tabs-plan.md
  • Phase 3 — Actions: desktopApp/plans/2026-07-27-feat-desktop-profile-actions-plan.md

Corrections fed back from per-phase research (supersede the sections below)

  1. Enforcement is ALREADY DONE on the base. DesktopHiddenUsersState (moderation-safety) already loads the kind-30000 block list and LiveHiddenUsers.isUserHidden already returns mute ∪ block. The new tab filters just read the existing hiddenUsersHashCodes — no new enforcement code, no commons extraction (retires Open Q #3 & #6, and the "unify enforcement now" task).
  2. Block is inline, not a commons builder. moderation-safety keeps mute inline on DesktopIAccount.updateMuteList; Block mirrors it (blockUser/unblockUser/updateBlockList) using quartz FollowSetEvent.addUser/removeUser. This overrides the "extract Block to commons/actions/" line below and resolves the architecture reviewer's two-parallel-patterns smell. (FollowPackActions may still be a thin commons builder.)
  3. observeEvents is a fallback, not a requirement. Existing Desktop tabs use a cache-scan AdditiveFeedFilter + rememberSubscription; Followers/Zaps follow that. Only add DesktopLocalCache.observeEvents (needs a new filter index) if the scan proves insufficient.
  4. DM ctor is ChatroomKey(setOf(pubkey)), not build1on1.

Reality Check — brainstorm assumptions vs codebase

Brainstorm assumption Codebase reality Revised decision
mute/block/report missing on Desktop mute + report built on unmerged feat/desktop-moderation-safety (DesktopIAccount.hideUser/showUser/report, desktop ReportNoteDialog, header overflow menu, DesktopHiddenUsersState, feed enforcement, NIP-36 blur). Not in this worktree (currently on main). Build on top; do NOT re-implement mute/report. Resolve the base first (see Base Strategy).
Extract mute/block/report to commons moderation-safety put mute/report desktop-local on DesktopIAccount New builders (Block, Add-to-pack) → commons/actions/ (matches FollowActions/ReportAction). Route Block's write through DesktopIAccount symmetric with updateMuteList. Unify enforcement now; extract shared HiddenUsersState as follow-up.
Rich-text bio needs extraction (open Q) DesktopRichTextViewer + commons RichTextParser already render feed cards Reuse as-is (~5-line wrap). Resolved.
Block = NIP-51 kind 30382 30382 = NIP-85 UserAssertionEvent (WoT). Block = FollowSetEvent 30000, d=mute Corrected throughout.

Base & Branch Strategy

The mute/report code this plan extends lives only on unmerged feat/desktop-moderation-safety; this worktree is currently on origin/main, so that code is not present here. Pick one before Phase 3:

  • Recommended — wait & rebase (default). Let moderation-safety merge to main, then rebase profile-parity onto main. Avoids stacking under the repo's nostr-proposal flow (three-mains alignment gate; a base-API change during review silently rots the child). See ngit-pr skill.
  • If you can't wait — stack. Recreate this worktree off feat/desktop-moderation-safety (so the code the plan extends is in-tree), set PR --base feat/desktop-moderation-safety, and name the exact contract depended on: DesktopIAccount.hideUser/showUser, the header overflow-menu composable, publishModeration, the zero-relay guard — so a base change is a compile break, not silent drift.

Phases 1–2 (banner, bio, CLINK, tabs) don't depend on moderation-safety and can proceed on main immediately. Only Phase 3's Block menu item touches the moderation surface.

  • Single feature branch (brainstorm), focused commits per phase.
  • ⚠️ Maintainer header overlap. upstream/claude/{redesign-profile-header, add-last-seen-profile, add-profile-settings-page, add-topbar-profile-screen, add-profile-upload-button} touch this header. Diff before touching it; keep header changes additive and minimal.

Scope

In scope (core parity)

Viewing

  • Banner image display (upload works; render it) — mirror DrawBanner.kt.
  • Rich-text bio: wrap about with DesktopRichTextViewer.
  • Six tabs: Followers, Following, Zaps received, Relays, Bookmarks, Mutual (see per-tab table).

Actions (mute/report already present via moderation-safety)

  • DM → open Desktop Messages for this pubkey.
  • Share (copy npub / nostr: link).
  • Add to list / follow-pack (reuse Desktop Follow Packs).
  • Block — FollowSetEvent kind 30000, d=mute (encrypted), distinct from the mute list;
    • Unblock. (Reuses existing quartz builder — see Technical Approach.)
  • Edit Profile: add the missing CLINK offer field (noffer1…).

Deferred to v2 (niche)

NIP-58 badges · NIP-85 petname/nickname card (kind 30382 — the WoT card) · on-chain BTC / Cashu / NIP-A3 chips (LN only) · Apps tab · Followed-tags tab · Reports-about-user tab · QR nprofile · last-seen (maintainer add-last-seen-profile likely covers) · pronunciation play.

Sequencing recommendation (from simplicity review — confirm as open Q)

Followers + Zaps carry the two heavy dependencies (observeEvents accessor; private-zap decryption + bunker cost + bespoke ProfileZapRow). Consider a core PR = Following, Followers, Bookmarks, Mutual, Relays + DM/Share/Add-to-list/Block, with Zaps as a fast-follow. Keeps the riskiest path off the critical PR. (Scope decision for the user — see Open Q #5.)

Actor × Subject Matrix (flow-gap review — highest-value addition)

Every action/tab needs defined behavior across whose profile × viewer auth state. Derive header visibility from this (self-guards prevent real footguns, e.g. self-block hiding your own feed):

Action / tab Own profile Other's profile Read-only / logged-out
DM hide show hide (or disabled+reason)
Block / Unblock hide (never allow self-block) show hide
Report hide show hide
Add to list hide show hide
Share show show show (no auth)
Edit (CLINK) show hide hide
Followers / Following / Bookmarks / Relays show show show
Zaps show (+ private zaps you can decrypt) show (public only) show (public only)
Mutual hide/empty (me tagging me) show hide (no "me")

Write-path template must guard pubKeyHex == account.signer.pubKey (self) and isWriteable() (read-only) before any publish.

Technical Approach

Key files (verified)

Purpose File
Desktop profile screen (tabs ~L847–1125, header ~L672–845) desktopApp/.../desktop/ui/UserProfileScreen.kt
Desktop profile feed filters (exemplar DesktopProfileFeedFilter) desktopApp/.../desktop/feeds/DesktopFeedFilters.kt
Desktop write path (FollowAction.follow → broadcastToAll) UserProfileScreen.kt L1202–1247
Desktop account bridge (mute/report on moderation-safety; privateZapsDecryptionCache = null stub; isHidden = false today) desktopApp/.../desktop/model/DesktopIAccount.kt
Relay publish (fire-and-forget; NIP-42 AUTH partial) desktopApp/.../desktop/network/RelayConnectionManager.kt (broadcastToAll)
Confirmed publish (reuse for Block) quartz accessories/ publishAndConfirm / desktop DmSendTracker.publishAndConfirmDetailed
Rich-text render (reuse for bio) desktopApp/.../desktop/ui/note/DesktopRichTextViewer.kt + commons richtext/RichTextParser.kt
Edit dialog (add CLINK) desktopApp/.../desktop/ui/profile/EditProfileScreen.kt + commons profile/EditProfileFields.kt
Follow Packs (add-to-list target) desktopApp/.../desktop/followpacks/
Desktop Messages (DM target) desktopApp/.../desktop/ui/chats/ (ChatroomListState.selectRoom(build1on1(pubkey)))
Block — reuse, don't rebuild quartz nip51Lists/followSet/FollowSetEvent.kt (KIND=30000, BLOCK_LIST_D_TAG="mute", addUser(earlierVersion, isPrivate=true)); Android blockPeopleList/BlockPeopleListState.kt; commons FollowSetDecryptionCache.kt
Zap decryption quartz PrivateZapCache(signer) / PrivateZapRequestBuilder.decryptZapEvent; Android UserProfileZapsViewModel
Commons actions (pattern) commons/.../actions/FollowActions.kt, commons/.../model/nip56Reports/ReportAction.kt

Prerequisite: add observeEvents(filter) to DesktopLocalCache

Followers + Zaps subscribe to a Filter stream; this accessor is missing on Desktop (Android uses account.cache.observeEvents<Event>(filter)). Add it (or an equivalent compose-scoped subscription) as the first task in Phase 2. Following/Bookmarks/Relays/Mutual do not need it.

Per-tab data logic (verified Android sources; tab filters stay desktop-local over the shared AdditiveFeedFilter base)

Tab Android class (verified) Query / accessor Desktop accessor status Renders
Followers UserProfileFollowersUserFeedViewModel observeEvents(Filter(kind 3, p=user)) → unique authors; filter !isHidden ⚠️ needs observeEvents; getOrCreateUser ✅ user rows
Following UserProfileFollowsUserFeedViewModel getOrCreateAddressableNote(ContactListEvent.createAddress(user)) → verifiedFollowKeySet() → load users ✅ all present user rows
Zaps UserProfileZapsViewModel observeEvents(Filter(kinds 9735+onchain, p=user)) → mapRequest; decrypt only if user==self; sumAmountsByUser ⚠️ needs observeEvents and PrivateZapCache(signer) (null stub today) zapper rows + total
Relays RelayFeedViewModel user.nip65RelayListNote.flow() (write/read) + user.dmRelayListNote.flow() (kind 10050) + user.relayState().flow() counters ✅ all present relay rows (reuse RelaySettingsScreen row)
Bookmarks UserProfileBookmarksFeedFilter getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user)) → publicBookmarks() (+ legacy OldBookmarkListEvent 30001) → resolve notes ✅ (confirm checkGetOrCreateNote) FeedNoteCard
Mutual UserProfileMutualFeedFilter iterate notes + addressableNotes; author==userProfile() AND event.isTaggedUser(user); limit 200 ✅ all present FeedNoteCard

Followers/Following/Zaps/Relays need row composables (reuse UserSearchCard-style user row and RelaySettingsScreen relay row where possible — avoid net-new). Bookmarks/Mutual reuse FeedNoteCard.

Write-path template (all actions — with the guards the reviews demand)

// 1. actor/subject guards FIRST
require(pubKeyHex != account.signer.pubKey)   // never self-DM/block/report
if (!account.isWriteable()) return             // read-only/watch-only: no-op, UI already hides

// 2. NIP-51 lists (Block): reuse existing decrypt→merge→encrypt; NEVER create() an existing list
val current = blockListState.getBlockList()    // must be LOADED from relays first (see guard)
val event = FollowSetEvent.addUser(earlierVersion = current, pubKeyHex, relayHint, isPrivate = true, signer)
// fail-closed: addUser throws UnauthorizedDecryptionException rather than dropping private entries

// 3. publish. Block = security-relevant → confirm, don't fire-and-forget
if (relayManager.connectedRelays.value.isEmpty()) throw IllegalStateException("No relays")
relayManager.publishAndConfirm(event)   // Block; plain broadcastToAll acceptable for non-critical
account.justConsumeMyOwnEvent(event)    // optimistic local apply, symmetric with updateMuteList
  • Stale-list clobber guard: Block/Add-to-list must be disabled until the account's own current list (30000 / 39089) has loaded — mutating a stale/absent earlierVersion publishes a replacement that drops prior entries (silent un-block / lost pack members).
  • Optimistic rollback: on publish failure, revert the optimistic local apply + snackbar.
  • Modals: the pack-picker and any block-confirm dialog must not use rememberSubscription (broken inside AlertDialog) — hoist the subscription or use direct relayManager.subscribe().

System-Wide Impact

  • Unified enforcement (do now): isUserHidden must union mute ∪ block through one combined hidden-set (mirror Android HiddenUsersState composing muteList+blockList into LiveHiddenUsers; keys include hiddenUsersHashCodes: Set<Int> — feed the filters the same representation). Every new tab (Followers/Following/Zaps/Mutual/Bookmarks) must respect it — new list surfaces are where enforcement is forgotten.
  • Block publish reliability: broadcastToAll is fire-and-forget and Desktop NIP-42 AUTH is partial → a Block can be accepted-but-not-persisted on an AUTH relay. Use confirmed publish for Block; snackbar covers zero-relay, confirmation covers not-persisted.
  • Privacy caveat: an encrypted 30000 list still leaks that you keep a block list (kind + pubkey + cadence) to every connected relay; members stay NIP-44 encrypted. Don't advertise Block as fully private.
  • Bunker cost: private-zap decryption via a NIP-46 remote signer is one round-trip per zap (PrivateZapCache LRU 1000). Decrypt lazily (visible rows / on tab open), not eagerly.

Implementation Phases

Phase 1 — Header polish (small, additive; no moderation-safety dep)

  • Render banner (mirror DrawBanner.kt).
  • Wrap bio with DesktopRichTextViewer.
  • Add CLINK offer to EditProfileScreen + EditProfileFields + kind-0 write.
  • Diff upstream/claude/redesign-profile-header first.

Phase 2 — Six tabs (no moderation-safety dep)

  • First: add observeEvents(filter) to DesktopLocalCache (+ compose-scoped subscription).
  • Wire PrivateZapCache(signer) into DesktopIAccount (replace null stub); gate on isWriteable().
  • New desktop filters mirroring the verified Android classes above.
  • Row composables: reuse user-row + relay-row; new ProfileZapRow only if Zaps stays in core.
  • Tab headers with counts/totals; per-tab loading / empty / partial states (see below).

Phase 3 — Header actions (Block depends on base strategy)

  • DM: onStartDmWith(pubKeyHex) → Main.kt pushes Messages + selectRoom(build1on1); handle target-has-no-inbox-relays / read-only gracefully (no crash).
  • Share: copy nostr:/npub (reuse copy-npub util).
  • Add to list: commons/actions/FollowPackActions.kt (build/publish kind 39089) + pack-picker modal; zero-packs → offer "Create new pack" (no dead-end).
  • Block: thin commons/actions/BlockActions.kt adapter over FollowSetEvent.addUser(earlierVersion, isPrivate=true); route write through DesktopIAccount symmetric with updateMuteList; add to header overflow menu; enforce via unified hidden-set.

Phase 4 — Tests & manual sheet

  • Unit: each new filter; Block round-trip preserves prior private entries (mirror MuteListEventTest.add_eventTagPreservesPriorUserAndWordTags); Block decrypt-fail is fail-closed (no silent drop); isUserHidden = mute ∪ block; CLINK round-trip; zaps own-profile decryption vs other-profile public fallback.
  • spotlessApply + :commons:compileKotlinJvm + :desktopApp:compileKotlin + relevant jvmTest.
  • Manual sheet desktopApp/plans/2026-07-27-desktop-profile-parity-manual-testing.md.

Per-tab UI states (flow-gap review)

Every tab defines loading / empty / populated; Followers + Zaps add a fourth partial:

  • Followers/Following count is cache-limited — you only see followers whose kind-3 is cached. Either label "N found" (not a false exact total) or use a NIP-45 count query. Don't show a wrong exact count.
  • 10k lists: LazyColumn windowing; no full materialization.
  • Bookmarks/Mutual notes not yet in cache: loading placeholder, not blank.
  • Zaps: on a third-party profile, label "public zaps only" (private undecryptable).

Acceptance Criteria

Functional

  • Banner renders; bio renders mentions/hashtags/links/emoji.
  • Six tabs populated with correct headers; Followers count is honest (labeled partial or NIP-45).
  • Zaps: own-profile sums private+public; other-profile sums public and is labeled "public only".
  • DM opens the correct 1:1 room; graceful state when target has no inbox relays.
  • Share yields a valid nostr:/npub link.
  • Add-to-list adds to a chosen pack (39089) and persists; zero-packs offers create-new.
  • Block (kind 30000, d=mute) hides the user across feeds/threads/replies/profile; Unblock reverses; distinct from mute.
  • Edit Profile CLINK field round-trips through kind 0.
  • Mute + report (moderation-safety) still work unchanged.

Actor/auth & states

  • Own profile hides DM/Block/Report/Add-to-list; shows Share+Edit. Self-block impossible.
  • Read-only/logged-out: write actions hidden; view tabs + Share work.
  • Each tab has distinct loading vs empty states; large lists scroll windowed.

Correctness / safety gates

  • isUserHidden = mute ∪ block, enforced across all six new tabs (one chokepoint).
  • Block round-trip preserves prior private entries; Block disabled until own list loaded; decrypt-fail is fail-closed.
  • Block publish is confirmed (not fire-and-forget); optimistic UI rolls back on failure.
  • PrivateZapCache(signer) wired + isWriteable() gate; decryption lazy (no bunker storm).

Quality

  • New builders in commons/actions/ as pure builders (Block = thin adapter over existing quartz API — never create() an existing list).
  • Tab subscriptions lifecycle-scoped; modals don't use rememberSubscription.
  • spotlessApply clean; commons + desktopApp compile; unit tests green.
  • Header changes reconciled against upstream/claude/redesign-profile-header.

Dependencies & Risks

  • Base: moderation-safety must merge (rebase) or be stacked; currently not in-tree. Only Phase 3 depends on it.
  • observeEvents accessor is a hard prerequisite for Followers + Zaps.
  • Private-zap decryption stubbed null on Desktop; own-profile-only; bunker round-trip cost.
  • NIP-51 replaceable clobber (stale earlierVersion) = data-loss bug → load-latest guard + test.
  • Maintainer header redesign conflict → additive-only changes.

Open Questions

  1. Block vs mute UX: expose separate Block (30000) in addition to mute, or fold into a combined "block & mute" like Android's dialog? (Simplicity review argues fold/defer since the user-facing effect overlaps; security/arch confirm they're genuinely distinct events. User call.)
  2. Follow-pack picker: modal vs route into FollowPackDetailScreen; zero-packs create-new flow.
  3. Reconcile moderation-safety mute → commons: enforcement unified now; is the shared HiddenUsersState extraction in-scope or a follow-up?
  4. Block confirm strategy: publishAndConfirmDetailed (like DMs) vs optimistic+retry?
  5. Sequencing: ship Zaps (+ maybe Relays) as a fast-follow to keep observeEvents/decryption off the core PR? (Simplicity review recommends yes.)
  6. Own 30000 block-list StateFlow: does moderation-safety already load it, or is that new wiring here?

Sources & References

Origin

  • Brainstorm: docs/brainstorms/2026-07-27-feat-desktop-profile-parity-brainstorm.md.

Verified Android tab sources

  • amethyst/.../profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt
  • amethyst/.../profile/follows/dal/UserProfileFollowsUserFeedViewModel.kt
  • amethyst/.../profile/zaps/dal/UserProfileZapsViewModel.kt
  • amethyst/.../profile/relays/RelayFeedViewModel.kt
  • amethyst/.../profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt
  • amethyst/.../profile/mutual/dal/UserProfileMutualFeedFilter.kt

Verified shared / quartz

  • quartz/.../nip51Lists/followSet/FollowSetEvent.kt (KIND 30000, d=mute, addUser)
  • amethyst/.../model/nip51Lists/blockPeopleList/BlockPeopleListState.kt
  • commons/.../model/nip51Lists/followSets/FollowSetDecryptionCache.kt
  • quartz PrivateZapCache / PrivateZapRequestBuilder.decryptZapEvent
  • commons/.../actions/FollowActions.kt, commons/.../model/nip56Reports/ReportAction.kt
  • commons/.../richtext/RichTextParser.kt, desktopApp/.../ui/note/DesktopRichTextViewer.kt
  • Android HiddenUsersState / LiveHiddenUsers (mute ∪ block enforcement model)

Desktop targets / gaps

  • desktopApp/.../ui/UserProfileScreen.kt, feeds/DesktopFeedFilters.kt, model/DesktopIAccount.kt (privateZapsDecryptionCache null stub; isHidden false), network/RelayConnectionManager.kt (broadcastToAll fire-and-forget), followpacks/, ui/chats/.
  • Missing: DesktopLocalCache.observeEvents(filter); confirm checkGetOrCreateNote.
  • origin/feat/desktop-profile-editing (MERGED), origin/feat/desktop-rich-text-and-profile (MERGED), origin/feat/desktop-moderation-safety (OPEN — base), upstream/claude/{redesign-profile-header, add-last-seen-profile, add-profile-settings-page, add-topbar-profile-screen, add-profile-upload-button}.