Filter.match is itself just a boolean predicate, so carrying a Filter and
a separate composition predicate on NewEventMatchingFilter duplicated the
abstraction. Collapse to one predicate:
- NewEventMatchingFilter(predicate, onNew): drops the Filter parameter.
- LocalCache.observeNewEvents(predicate): primary API.
- LocalCache.observeNewEvents(filter): kept as a one-liner that forwards
filter::match, so existing feed/DAL callers are unchanged.
In the dispatcher the freshness check, kind check, p-tag match, and
since cutoff now live in a single lambda, short-circuiting on cheap
kind comparison first. NOTIFICATION_KINDS is now a Set<Int> for O(1)
membership instead of O(n) List.contains — cheap, but on a hot path
that runs for every new cache insertion it pays for itself.
https://claude.ai/code/session_01GQDJxiHPogdzCNhUBN7Pjc
Rolled the compact header pattern out to the screens the earlier pass
missed. Each one now lives on the same padding/typography rhythm as
Messages: Row(fillMaxWidth, padding horizontal = 12, vertical = 8),
titleMedium for labels, IconButton size = 32dp with 20dp icons tinted
with colorScheme.primary.
- ThreadScreen: back button + "Thread" title. Was headlineMedium with
bottom-only padding; now compact row.
- UserProfileScreen: back + "Profile" title on the left, Edit /
Follow buttons kept on the right (they stay as text buttons — they
represent destructive intent, not icon affordances).
- ArticleReaderScreen: back + "Article" title; zoom % label still
surfaces next to the title when != 100%.
- ArticleEditorScreen: back IconButton + "Article" title (was a text
"Back" OutlinedButton with no title). Save / Publish buttons kept
on the right — same reasoning as UserProfileScreen.
- ChessScreen: back (conditional) + "Chess" / "Live Game" title;
refresh + New Game converted from Button → IconButton so the
header reads at the same scale as the rest.
- RelayDashboardScreen: had no header at all, just a PrimaryTabRow.
Converted Monitor / Configure to FilterChip tabs-first (matching
Feed / Reads) so the selected tab is the title.
FeedHeader relocation:
- Moved commons/ui/feed/FeedHeader.kt → desktopApp/ui/FeedHeader.kt.
Only NotificationsScreen.kt referenced it, and dropping the import
from that file was enough because it's now in the same package.
- Removed the empty commons/ui/feed/ directory.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Refactored the three feed screen headers so they all match the Messages
pattern — single compact row padded horizontal = 12, vertical = 8, no
oversized titles, no multi-row metadata.
FeedScreen (Home):
- Replaced the `headlineMedium` "Global Feed" / "Following Feed" title
plus a redundant Global/Following chip row plus a separate relay-count
meta-line plus a large "New Post" button with:
[ Following | Global ] [relays] [refresh] [+ compose]
The selected tab IS the screen title. Relay count + followed-user
count surface through a hover tooltip on the relays icon (desktop
convention; info is preserved without taking a whole row).
- Relays icon click opens the picker when the account can edit, falls
through to navigate-to-relays otherwise.
ReadsScreen:
- Same treatment: dropped the "Reads" label, lifted Following/Global
chips to the left, single refresh icon on the right. "N relays
connected" moved to the refresh button's content description.
commons/FeedHeader (used by NotificationsScreen):
- Dropped the RelayStatusIndicator (icon + count text + refresh button,
took 3 slots). Now just titleMedium on the left + a single refresh
IconButton on the right with relay count in its content description,
matching the Messages "titleMedium + icon buttons" rhythm.
- Internal padding(horizontal = 12, vertical = 8) baked in so callers
don't need a trailing Spacer.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Mark padding inside the squircle down from 7.5% to 5% each side, so the
goose fills the squircle a bit more fully and matches the visual weight
of neighboring dock icons.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Icon:
- Mark now fills ~85% of the squircle (was 80%) so the goose reads at
the same visual weight as first-party dock icons. Apple's template
specifies 7.5-10% padding inside the squircle; 7.5% lands closer to
the average first-party icon.
- Baked a drop shadow into the icon PNG: 8px offset, 18px Gaussian
blur at ~28% black, rendered under the white squircle on its own
layer so the blur doesn't leak into the mark. First-party macOS
icons include this shadow in the PNG — the dock doesn't add one at
render time. Separable Gaussian (2x 1D passes) keeps startup fast.
Screen header consistency (match Messages pattern):
- Bookmarks, Drafts, Search, Reads, Highlights: titles switched from
headlineMedium to titleMedium.
- All five header rows now pad horizontal = 12.dp, vertical = 8.dp
(matching ConversationListPane's "Messages" header).
- Removed the 16.dp outer wrapper padding from Bookmarks and the 16.dp
bottom padding from Reads header — screens now sit edge-to-edge.
- DeckColumnContainer's 12.dp outer padding around column content
removed for the same reason SinglePaneLayout's was: creates a
`#F5F5F7` frame around every screen that reads as inconsistent
with Messages (and every native desktop app).
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Previous impl filled the entire 1024x1024 canvas with the squircle.
Apple's macOS app icon template leaves ~100px of transparent margin
around a centered 824x824 squircle (content area is 80.5% of the
canvas width) — which is the size the dock normalizes to when laying
out next to first-party apps. Filling the whole canvas made Amethyst
render ~20% larger than its neighbors in the dock and Cmd+Tab.
Now matches Apple's reference geometry:
- Canvas: 1024x1024 (transparent margins of 100px)
- Squircle: 824x824 centered
- Corner radius: ~185px (22.45% of the squircle, per HIG)
- Mark: 10% padding inside the squircle
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
SinglePaneLayout wrapped the main content area in `.padding(12.dp)`,
leaving a visible strip of the window background around every screen.
On macOS with the theming updates this strip became very noticeable:
conversation list, chat content, and the NavigationRail no longer
shared a single edge, and you could see `#F5F5F7` framing the whole
content block.
Native desktop apps don't frame content like that — padding is added
inside cards / lists / dialogs, not around the window content area.
Removing the wrapper padding lets the Messages two-column layout
(surfaceContainer sidebar + surface chat) run edge-to-edge.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
macOS Big Sur+ mandates the squircle icon template for dock / Cmd+Tab —
bare transparent logos stick out next to first-party apps. PlatformAppIcon
wraps the source logo in a 1024x1024 white squircle with 10% padding at
runtime, only when the host OS is macOS (PlatformInfo.host, not .current
— the override is for in-app theming, the dock is drawn by the real OS).
No extra image file: Java2D renders the squircle from the existing
icon.png so Linux / Windows paths stay untouched (they still get the
raw transparent logo, which is the right call for GNOME and matches
Windows 11 taskbar rendering).
Apple's true shape is a superellipse; Java2D lacks a primitive for it,
so RoundRectangle2D with 22.5% corner radius is the practical approx
(invisible difference at dock icon sizes). Applied to both
java.awt.Taskbar.iconImage (dock) and Window(icon=) (title-bar thumb).
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
The conversation list (left) was rendering on surface (white) while the
chat content (right) was inheriting the window background — backwards
from how Messages.app, Slack, Telegram, Discord lay out a two-pane
chat: secondary surface on the list side, primary (white in light
mode) on the content side.
- ConversationListPane root now paints surfaceContainer.
- Unselected ConversationCard goes transparent so the pane's
surfaceContainer shows through; selected / focused tints re-derived
from primary and onSurface so they still read on the new bg.
- Right-side chat Box now explicitly paints surface.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Window(icon = ...) only sets the in-title-bar proxy icon — it does NOT
drive the Cmd+Tab app switcher on macOS. That reads java.awt.Taskbar's
iconImage, which was never set, so gradle-run sessions showed the
generic Java coffee-cup square.
Load the PNG via ImageIO (preserves alpha), then set Taskbar.iconImage
before the application{} block starts. Guarded by isTaskbarSupported +
Feature.ICON_IMAGE so the call is a no-op on platforms without it.
Applies on all OSes that support the Taskbar API — macOS gets the dock
icon, GNOME/KDE get the task-switcher thumbnail, Windows gets the
taskbar icon.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Two separate readability fixes:
1. macOS light-mode primary was poor contrast on the gray background.
- Background was #ECECEC (Apple's title-bar chrome gray), now #F5F5F7
(Apple's secondarySystemBackgroundColor — used for main content).
- Surface container tones shifted accordingly so the sidebar
(surfaceContainer) is still visibly grayer than the content.
- Accents with high luminance (Apple Yellow, Graphite) now darken to
a readable shade in light mode via a luminance clamp that preserves
hue — saturated blue is untouched, but yellow pulls toward amber
so the primary color stays visible on near-white surfaces.
2. Window icon was the generic Java cup during `./gradlew :desktopApp:run`
because the Window composable had no `icon` param. The 100x100 icon
in resources also looked blocky in the dock. Now:
- icon.png replaced with the 512x512 transparent logo from
fastlane/metadata/android/en-US/images/icon.png.
- Window(icon = ...) loads it via Skia (non-deprecated path), shown
in the macOS dock / Windows taskbar / GNOME & KDE task switchers.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Previous approach reserved a 28dp strip across the whole top of the
window for traffic lights — wasting the area right of the lights that
Slack / Notion / Safari-style apps reuse for app content.
Now the sidebar / NavigationRail extends to the top of the window
(their surfaceContainer covers the traffic lights on the left), and
the main content area (deck columns / single-pane content) also
extends to the top since it's to the right of the lights.
- DeckSidebar: top padding now 8dp + titleBarInsetTop so icons clear
the traffic lights.
- SinglePaneLayout's NavigationRail: uses its `header` slot to hold a
Spacer of titleBarInsetTop, same effect.
- Removed the full-width title bar strip from MainContent.
- SinglePaneLayout NavigationRail container color switched from
surfaceVariant to surfaceContainer to match the deck sidebar and
the Material 3 sidebar convention.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Walks through the AMETHYST_PLATFORM / AMETHYST_APPEARANCE / AMETHYST_ACCENT
overrides — what they swap, what they don't (host-OS chrome stays), a
per-platform review checklist, and where each piece of theming code lives.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Adds three override hooks so a single-OS developer can preview every
platform's theming without VMs:
AMETHYST_PLATFORM=GNOME ./gradlew :desktopApp:run
AMETHYST_APPEARANCE=light ./gradlew :desktopApp:run
AMETHYST_ACCENT=#3584E4 ./gradlew :desktopApp:run
Each override accepts either an env var or a `-Damethyst.<key>=<value>`
JVM property (forwarded from the gradle invocation via build.gradle.kts).
- PlatformInfo: `amethyst.platform` accepts MACOS, WINDOWS, GNOME, KDE,
LINUX_OTHER, UNKNOWN. Adds a `host` accessor for code that needs the
real underlying OS (for a future fallback hook).
- PlatformAppearance: `amethyst.appearance` accepts light/dark.
- PlatformAccent: `amethyst.accent` accepts `#RRGGBB`, `RRGGBB`, or any
libadwaita accent name (blue, teal, green, yellow, orange, red, pink,
purple, slate).
Window chrome stays native to the host OS — overriding the platform
swaps in-app theming only, not the AWT title bar — so on a Mac you
still see traffic lights even when previewing the GNOME theme.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Replace the hard-coded Material3 dark theme with a per-OS theme that
follows the host system's appearance, accent color, fonts, and rounding
language. Adds a `desktop/platform/` module with:
- PlatformInfo: detects macOS, Windows, GNOME, KDE, other Linux via
XDG_CURRENT_DESKTOP / DESKTOP_SESSION.
- PlatformAppearance: reads OS dark/light preference (defaults on macOS,
gsettings on GNOME, kreadconfig on KDE, registry on Windows) and
refreshes on window focus.
- PlatformAccent: pulls the user's accent color from each OS (named
AppleAccentColor, gsettings accent-color, kdeglobals AccentColor,
DWM AccentColor) and falls back to Amethyst purple.
- PlatformFonts: resolves the OS's preferred UI font via Skia's
FontMgr (SF Pro Text on macOS, Segoe UI Variable on Win 11,
Cantarell/Adwaita Sans on GNOME, Noto Sans on KDE).
- PlatformShapes / PlatformTypography / PlatformColorScheme: per-OS
rounding (macOS 8/10/14, libadwaita 9/12/16, Breeze 6/8/12, WinUI
4/8/8), letter-spacing tightening for SF Pro / Adwaita, and surface
tones from each OS's reference palette.
macOS gets native chrome treatment: `apple.laf.useScreenMenuBar`
routes the MenuBar to the system menu bar; `apple.awt.transparentTitleBar`
+ `apple.awt.fullWindowContent` lets the deck/sidebar extend under the
traffic lights; a 28dp top strip in MainContent reserves space so
content doesn't underlap them.
DeckSidebar bumps from 48dp to 56dp (desktop density) and switches its
background to the Material3 `surfaceContainer` token, which our new
per-OS schemes drive to the right tone for each platform.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
The painter measured the glyph with TextStyle(color = Unspecified) and
tried to override the color at draw time via drawText(layout, color = tint).
That override is unreliable across Compose versions: when the measured
style carries Color.Unspecified, drawText can fall through to Color.Black,
producing black-on-black icons (visible on the back arrow in dark mode).
Include the tint in the measured TextStyle directly. The (symbol, fontFamily,
density, tint, rtl) remember key on rememberMaterialSymbolPainter was already
keyed on tint, so the TextMeasurer cache still hits for every (symbol, color)
pair used by the app.
MaterialSymbols glyphs have more internal padding than the previous
MaterialIcons, so at 20dp they looked smaller than before. Bump the
NavigationBar icons to 24dp (Material's default NavigationBar icon size)
and widen the notification-dot box accordingly to preserve its offset.
Replaces the video-style play/pause controls on GIFs with normal image
rendering. When Auto-play Videos is off, GIFs pause on the first frame
(feed GIFs additionally show a small "gif" label in the bottom-left);
tapping still opens the fullscreen dialog.
Profile-picture GIFs now respect the same setting. The key fix is
bypassing ProfilePictureFetcher (which serves a static JPEG thumbnail
and prevents animation); GIF avatars load via the raw URL so Coil's
animated decoder runs. Autoplay is read reactively from a new
autoPlayVideosFlow StateFlow so toggling the setting immediately
starts/stops animation in the drawer, top bar, and every other avatar.
Introduces `GifVideoView` to manage GIF playback with manual play/pause
controls and support for the "Auto-play Videos" setting. Updates
`MyAsyncImage` and `ZoomableContentView` to utilize this specialized
view when GIF content is detected via file extension or MIME type.
Key changes include:
* **GifVideoView**: A new component that uses Coil's `Animatable` to start and stop GIF animations, featuring a video-like UI with play/pause overlays and a zoom button.
* **GIF Detection**: Added `isGif()` and `isGifUrl()` helpers to identify GIF content by MIME type or URL patterns.
* **Integration**: Redirects GIF rendering in `MyAsyncImage` and `ZoomableContentView` from static image views to the new interactive `GifVideoView`.
Delete() returning false on a still-existing file meant we silently lost
scratch state for MLS group / key-package / message stores. Route the
four call sites through a `deleteOrWarn` helper that logs via the
KMP-safe quartz Log when the file refuses to disappear.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace FilledTonalIconButton with FilledTonalIconToggleButton so the
container color reflects raised/lowered state, and drop the tautological
`if (handRaised) PanTool else PanTool` conditional flagged by Sonar.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Material Symbol icons were allocating a fresh Font wrapper on every
composition (Font(resource = ...) returns a new instance each call),
which invalidated the remember(font) cache in rememberMaterialSymbolsFontFamily
and forced a fresh TextMeasurer per call site. Tint was also baked into
the TextStyle passed to measure(), so any tint change (selected /
unselected reaction icons, hover states) produced a cache miss.
Hoist the FontFamily + TextMeasurer to CompositionLocals provided once
at the root (AmethystTheme on Android, MaterialTheme wrapper on desktop)
via ProvideMaterialSymbols. Every icon in the subtree now reuses:
- One FontFamily (Skia typeface cache hit for every subsequent glyph)
- One TextMeasurer with a 64-entry LRU, shared across all call sites
so its measurement cache is hit across 300+ MaterialSymbols usages
- Tint applied via drawText(layout, color = tint) instead of TextStyle,
so the measured TextLayoutResult is reused across tint variations
Icon(symbol = ...) now delegates to Material3's Icon(painter = ...),
restoring proper sizing/semantics without the custom Box+drawBehind
workaround. autoMirror handled inside the painter's onDraw. The
previously-unused MaterialSymbolPainter is now the single render path.
The weight parameter is dropped from the Icon/Painter APIs since the
app uses a single weight globally (MaterialSymbolsDefaults.WEIGHT).
A local fallback path keeps @Preview composables that don't wrap in
the theme renderable (per-composition allocation, same cost as before).
https://claude.ai/code/session_01V9esGUFH72ujCQNWCAnjKN
Audit of every notify() path found self-exclusion + 15-min age checks
duplicated across nearly all of them, with two inconsistencies (DM kind 4
and zap kind 9735 were missing explicit self-checks). Reactions, zaps,
and chess also re-ran isTaggedUser even though consumeFromCache already
routes by the same `p` tag.
Collapse the duplication into two layers:
- Observer layer (NotificationDispatcher): 15-min rolling age cutoff is
now enforced by a predicate on LocalCache.observeNewEvents, before any
account routing happens. The Nostr Filter grammar's `since` is a fixed
value from dispatcher start; the predicate re-evaluates TimeUtils.
fifteenMinutesAgo() per event so the window rolls forward as wall-clock
time advances.
- Per-account layer (dispatchForAccount): right after the call/wake-up
branch and the MainActivity.isResumed gate, drop events authored by
the current account. Kept per-account (not observer-wide) because in a
multi-account session account A's outgoing event legitimately becomes
account B's incoming notification on the same device, so a device-wide
author-exclusion set would swallow A→B.
Mechanically, NewEventMatchingFilter now takes an optional predicate so
observers can reject on fields the Filter grammar can't express (like a
moving `createdAt` cutoff). LocalCache.observeNewEvents adds a matching
overload that forwards it.
With the gates centralized, every notify() method drops its own age and
self-author checks (and for reaction/zap, the redundant isTaggedUser).
notifyWelcome keeps its own guards because it's dispatched directly
from processMarmotWelcomeFlow, bypassing the observer and dispatchForAccount.
Calls and wake-ups also bypass the shared gates by their short-circuit
return before the shared block.
https://claude.ai/code/session_01GQDJxiHPogdzCNhUBN7Pjc
The Canvas-based MaterialSymbol Icon collapsed to 0x0 whenever callers
didn't pass an explicit size modifier (e.g. ArrowBackIcon, ClearTextIcon).
Canvas is a Spacer + drawBehind, and Spacer's measure policy only uses
maxWidth/maxHeight when the incoming constraints are fixed. Inside an
IconButton (state layer 40dp, content constraints 0..40), defaultMinSize
only lifts minWidth/minHeight, leaving the constraints unfixed - so
Spacer picked 0x0 and the glyph never drew. Icons that passed
Modifier.size(N.dp) worked only because size(...) produces fixed
constraints.
Swap Canvas for an empty Box with drawBehind. Box's EmptyBoxMeasurePolicy
uses minWidth/minHeight, so defaultMinSize(24, 24) now takes effect when
no size modifier is supplied - matching Material3's own Icon behavior.
https://claude.ai/code/session_01V9esGUFH72ujCQNWCAnjKN
The dispatcher previously observed every new event whose kind fell in
NOTIFICATION_KINDS, which on a live feed is effectively every kind-1,
video, picture, long-form, reaction, etc. — the vast majority of which
got discarded at consumeFromCache when the tagged-user set didn't
intersect any saved account.
Rebuild the observer Filter off LocalPreferences.accountsFlow() with:
- tags = mapOf("p" -> <signer pubkeys hex>) — match only events that
p-tag one of our notifiable accounts, which is exactly the set
consumeFromCache was going to route anyway
- since = <dispatcher start time> — relay-style `limit: 0`
so historical re-broadcasts from before this session don't retrigger
The job collects accountsFlow via collectLatest + distinctUntilChanged,
so login/logout/add-account events cleanly cancel the old observer and
resubscribe with the new pubkey set. allSavedAccounts() is called once
up-front to prime the backing StateFlow (which is null until first
suspend read). Kinds with the uppercase-P root-author marker (NIP-22
CommentEvent root authors) still reach us via the same lowercase-p
tag the reply builder also emits on direct replies.
https://claude.ai/code/session_01GQDJxiHPogdzCNhUBN7Pjc
A previous commit misread the spec: p-tags on a WakeUp identify the
AUTHORS of the referenced events (the people whose events are the
subject of the wake-up), not the recipients. The consumer's existing
npub-in-p-tag match is therefore correct — "is this logged-in account
the author of a referenced event?".
- Revert WakeUpEvent.build() back to notify(about.toPTag()) and replace
the comment with a spec-accurate one.
- In wakeUpFor, source author pubkeys from event.authorKeys() (p-tags,
canonical) first, merge in the e-tag author hints, and fall back to
the WakeUp signer only when both are empty. Bound by MAX_WAKEUP_REFS.
computeReplyTo and the referenced-event fetch path remain untouched —
those fixes are orthogonal to the p-tag semantic.
The WakeUp notification path had three latent bugs that caused pushed
WakeUps to silently not deliver anything to the user:
- WakeUpEvent.build() tagged the about event's AUTHOR as the recipient
(via EventHintBundle.toPTag()), so a WakeUp about a zap from Alice to
Bob would p-tag Alice. The consumer matches recipients by p-tag, so
Bob — the intended recipient — was filtered out. Forward the about
event's audience (its own p-tags) instead.
- wakeUpFor() passed the WakeUp's own note to EventFinderQueryState. The
finder's filterMissingEvents only queries for the note itself (already
in cache) or its replyTo (empty because computeReplyTo had no WakeUp
case). The referenced events were never REQ'd on relays. Link the
referenced events via computeReplyTo and subscribe the finder on each
referenced note directly so filterMissingEvents picks them up.
- UserFinder was subscribed against the WakeUp's own pubKey (typically a
push bot), not the author of the event the notification is about.
Pull author pubkeys from the e-tag author hint, falling back to the
WakeUp signer only when the hint is absent.
Also: bound e-tag count per WakeUp to 16 to prevent subscription
flooding, log the 30s timeout, extract WAKEUP_WINDOW_MS constant, drop
the now-unused Note parameter from wakeUpFor.
Extend the Mentions channel beyond kind-1 text notes to cover every
public content kind that can p-tag the user:
- PictureEvent (20)
- VideoNormalEvent (21), VideoShortEvent (22),
VideoHorizontalEvent (34235), VideoVerticalEvent (34236)
- ChannelMessageEvent (42)
- PollEvent (1068)
- GitPatchEvent (1617), GitIssueEvent (1621)
- HighlightEvent (9802)
- LongTextNoteEvent (30023)
- WikiNoteEvent (30818)
NotificationDispatcher observes these kinds so LocalCache insertions
wake the consumer. dispatchForAccount routes all of them through a
shared catch-all branch to notifyMention, which was generalized from
TextNoteEvent to Event. The existing self-authored and 15-min filters
now live inside notifyMention so the catch-all branch doesn't have to
repeat them per kind.
Replies (kind 1 to our own notes, kind 1111 comments with us as root
or direct-reply author) still land on the Replies channel; all other
p-tag citations fall to Mentions.
https://claude.ai/code/session_01GQDJxiHPogdzCNhUBN7Pjc
- Split kind-1 routing in EventNotificationConsumer: if the reply target
is authored by the current account it still lands on the Replies
channel; any other p-tagged kind-1 (plain mention, citation, reply to
someone else in a thread we're in) lands on a new dedicated Mentions
channel. Users can disable either independently from Android settings.
- Group reply notifications by thread root. Compute the root via the
NIP-10 marked/unmarked root markers for kind 1 and the NIP-22 root
event/address tags for kind 1111, falling back to the direct parent.
Use a per-thread group key + stable per-thread summary id so Android
collapses "5 replies to your thread" into one entry instead of one
big flat stack.
- Add an inline Reply action on reply notifications (kind 1 + kind 1111)
using RemoteInput, mirroring the DM flow. NotificationReplyReceiver
now handles PUBLIC_REPLY_ACTION: resolves the target event from
LocalCache and signs a NIP-10 reply (TextNoteEvent.build with
replyingTo hint) or a NIP-22 comment (CommentEvent.replyBuilder),
broadcast via account.signAndComputeBroadcast. The relay proxy is
held open for the duration via the same runOnRelay helper the DM
flow uses.
https://claude.ai/code/session_01GQDJxiHPogdzCNhUBN7Pjc
When the always-on notification service is on, preload every saved writable
account into AccountCacheState so each one's newNotesPreProcessor subscribes
to LocalCache.live.newEventBundles. Without this, relay-delivered GiftWraps
addressed to non-active accounts would sit in LocalCache undeconstructed,
and NotificationDispatcher never fires for them.
The preload observes LocalPreferences.accountsFlow so accounts added during
a session (login flow) join automatically. On disable, the collector is
cancelled and every cached account except the active one is released —
users with the setting off keep today's single-account battery footprint.
Loading an account is idempotent (AccountCacheState checks cache first),
so this cannot double-init or conflict with PushWrapDecryptor's on-demand
loadAccount calls. Loading does not affect the active account selected by
AccountSessionManager, so the UI is unaffected.
Adds a dedicated "Replies" notification channel that fires when someone
replies to one of the user's posts. Handled on two kinds:
- TextNoteEvent (kind 1): notifies only when the reply target
(`replyingTo()`) is authored by the current account, so plain p-tag
mentions are not routed through this channel.
- CommentEvent (kind 1111): notifies when the current account is the
NIP-22 reply author or root author of the parent scope.
The channel uses its own group key and summary so users can disable it
independently from OS-level notification settings, just like Zaps,
Reactions, DMs, and Chess.
https://claude.ai/code/session_01GQDJxiHPogdzCNhUBN7Pjc