Files
buzz/desktop/src-tauri/src/models.rs
T
Will PflegerandGitHub f35930104b fix(desktop): remove 0.5.9+ perf regressions, speed up get_channels (#5599)
Desktop input latency regressed sharply for users on v0.5.9 and worsened
on latest main: multi-second stalls when clicking back into the app,
slow fresh boots, intermittent lockups, and scroll/mouse degradation.
Reverting to `119a84897` (pre-0.5.9) was confirmed to resolve it,
isolating the regression to that range. Profiling a live production
renderer plus a commit-level audit of the range found three independent,
additive causes — fixed here — plus a long-standing `get_channels` cost
that made every remaining refetch expensive, also addressed here.

## 1. Focus-return refetch storm (`refetchOnWindowFocus`)

#5490 wired TanStack's `focusManager` to app focus and flipped ~20 query
sites to `refetchOnWindowFocus: true`. A focus return after >60s away
fires them all within milliseconds — and a click into an unfocused
window *is* a focus return, so the burst runs before the click is
processed. That is the "click into the composer, wait 5 seconds"
symptom, and it also explains why mouse input feels worse than keyboard
(clicks arrive with focus transitions; typing happens while already
focused). A 5-second `sample` of a live production renderer caught a
single window activity-state transition consuming ~1.25s of main-thread
time, dominated by `JSON.parse` in the focus listener's microtask drain.

#5535 already established the fix pattern but applied it to only two
families (channels, home-feed). This PR extends the same 5-minute
`staleTime` discipline to the remaining families: pulse (×5), workflows
(×4), agents (×4), forum (×2), presence, user-status, custom-emoji,
channel-templates, and the persona catalog. Polling cadences and
push-invalidation paths are untouched — interval refetches and
`invalidateQueries` both bypass `staleTime`, so live-update behavior is
unchanged. Each gated family exports its focus-refetch policy as an
options object that the production hook spreads into `useQuery`, and a
`focusRefetchPolicy.test.mjs` drives a `QueryObserver` with that same
production object — locking the policy behaviorally (fresh focus return
→ 0 fetches; stale → refetch) and failing if a hook's
`staleTime`/`refetchOnWindowFocus` wiring drifts.

Four families deliberately keep tighter freshness, all surfaces where
the 5-minute gate would suppress the only refresh path and none of which
feed the app-wide storm: `repo-sync-status` keeps its fresh focus
refetch (its inline comment documents the "committed in a terminal,
switched back to the app" flow as intended); the workflow-runs list
stale-gates at 10s because a remotely-started run has no push
invalidation and its conditional 1s poll is off while the cache shows no
active runs; the workflow list queries (`useChannelWorkflowsQuery` and
the all-channels aggregate) stale-gate at 10s because they have no poll
and no relay subscription, and mutation-driven invalidation only covers
this renderer — remote workflow creates/edits/deletes surface only via
focus refetch; and the managed-agent log stale-gates at one poll tick
(30s) so returning to a live agent log refreshes immediately. Run
approvals keep the 5-minute gate under
`RUN_APPROVALS_FOCUS_STALE_TIME_MS` — their focused 10s poll already
covers freshness.

## 2. Synchronous localStorage sweep on the boot/focus path

#5453's stale-cache sweep synchronously `getItem` + `JSON.parse`s every
whitelisted localStorage entry on the main thread (multi-MB on seasoned
profiles), scheduled with a `requestIdleCallback` timeout of 1.5s that
guaranteed it landed mid-boot, and re-armed on every hidden→visible
transition — stacking it onto the exact moment the focus storm fires.
#5454's `trimSelfProfileCaches()` additionally scanned every
localStorage key on every `writeSelfProfileCache()` call (which fires
per relay self-profile delivery at boot).

Now: the first sweep waits `BOOT_SWEEP_FLOOR_MS` (30s) after startup,
the scan is time-sliced across idle callbacks, and the visibility
trigger is removed — boot-delayed plus hourly still covers the 14-day
TTL contract. The sliced sweep re-checks staleness immediately before
each removal (a key rewritten fresh mid-sweep survives), isolates
per-key storage errors so one bad entry can't strand the rest of the
snapshot, defers oversized values once rather than parsing them on a
zero-budget slice, guarantees forward progress on timeout-fired
callbacks, and cancels its scheduled slice when stopped. The profile
trim keeps a lazily-initialized memoized key count so the common
under-cap write is O(1); the full parse scan runs only when the count
exceeds a cap, resyncs if external deletions made it stale, and a failed
scan skips the trim instead of aborting the write. Sweep semantics
(rules, TTLs, eviction) are unchanged, and tests cover the scheduling,
slice-progress, error-isolation, defer-once, and trim short-circuit
behaviors.

## 3. The macOS window was never opaque

#5478's glass appearance is correctly opt-in at the CSS layer, but the
compositor cost was baked in deeper than its native `on_webview_ready`
transparency call: the main window is declared `"transparent": true` in
`tauri.conf.json` (added for the original glass work in #1671), which
makes tao call `NSWindow.setOpaque(false)` at creation and resolve every
later `set_background_color(None)` to `clearColor` — and no runtime
`setOpaque(true)` path exists through tauri, while wry's runtime
background setter can only force the WKWebView's `drawsBackground` off,
never back on. So "restore the platform default" was unreachable: every
launch, glass or not, ran with a non-opaque NSWindow, defeating
WindowServer's opaque-window compositing fast path and forcing full
window compositing every frame — compounded by the existing
`backdrop-blur` chrome overlapping the scrolling timeline. This matches
the compositor-shaped symptoms (scroll and pointer input degrading
first).

The window is now created opaque (`"transparent": false`) and the
NSWindow layer is never made transparent at runtime. Glass never needed
a transparent window: behind-window `NSVisualEffectView` vibrancy
renders inside opaque windows (this is how Finder and Notes draw vibrant
sidebars); it only requires a transparent WKWebView canvas, which the
`set_window_vibrancy` enable path already establishes at runtime
(`macos-private-api` compiles that in independent of the window flag).
Enabling glass installs the vibrancy layer and then makes only the
webview canvas see-through; disabling clears the vibrancy layer — the
canvas may stay non-drawing afterwards (wry's flag is one-way at
runtime), which is harmless because glass-off CSS paints fully opaque
above an always-opaque NSWindow. The boot-path first-frame backing
writes touch only the NSWindow backing color and are therefore inert to
glass state regardless of how they order against the `ThemeProvider`'s
vibrancy call on a persisted-glass-on cold boot. Glass-off users (the
default) get an end-to-end opaque window from boot for the first time.

## 4. `get_channels`: serial round-trips and a multi-MB payload on every
refetch

The stale gates in (1) cut refetch frequency; this cuts the cost of the
refetches that legitimately remain (boot, and focus returns after more
than 5 minutes away — previously still a multi-second stall).
`get_channels` made ~8 fully serial relay round-trips (~3.2–3.6s at
1,100+ channels), then shipped the full `ChannelInfo` list — including
every channel's member pubkeys — across IPC, where the renderer's
`JSON.parse` of the multi-MB payload froze the main thread (the ~1.25s
stall captured in the live sample).

- **Concurrent stages**: the membership chain, the open-channel
directory scan, and the hidden-DM snapshot run concurrently, as do the
member-count and last-message queries that follow. The critical path
drops from ~8 sequential round-trips to 2 phases. Filters, limits,
pagination, and merge semantics are unchanged.
- **Not-modified short-circuit**: the command now takes a
client-supplied content hash (FNV-1a 64 over the channel list,
canonicalized by id and excluding `last_message_at`) and omits the
channel list from the response when nothing else changed. Last-message
timestamps — which change on nearly every message anywhere — ship as a
small separate map that the client overlays onto its cached list with
reference preservation, so React Query's structural sharing also skips
downstream re-renders. On a typical refocus the renderer parses
kilobytes instead of megabytes. The hash is stored in the query cache
itself, tying its lifecycle to the data it describes so a community
switch can never leak a stale hash.

The E2E mock bridge speaks the new payload shape — including the
complete `last_messages` map the client treats as authoritative — and
hash canonicalization plus overlay reference-preservation are
unit-tested on both sides.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2026-08-11 19:47:19 -04:00

395 lines
12 KiB
Rust

use std::collections::HashMap;
use serde::{Deserialize, Deserializer, Serialize};
#[derive(Serialize)]
pub struct IdentityInfo {
pub pubkey: String,
pub display_name: String,
/// Durable location of the active identity key.
pub storage: String,
/// True when the app booted with an ephemeral key because the OS keyring
/// was empty despite a prior successful migration (key was externally
/// deleted). The frontend routes to the nsec re-import step when true.
/// Mutually exclusive with `locked`.
pub lost: bool,
/// True when the app booted with an ephemeral key because the OS keyring
/// holding the identity is unreachable this boot (keyring locked or
/// unavailable). The real key still exists in the keyring; the frontend
/// shows a "unlock the keyring and relaunch" screen. Mutually exclusive
/// with `lost`.
pub locked: bool,
/// True when the boot-time Phase 2 reset attempted a wipe but verification
/// failed. Identity resolution was skipped; the frontend shows a
/// reset-failed recovery screen. The sentinel is preserved so the next
/// relaunch retries the wipe automatically.
pub reset_failed: bool,
}
#[derive(Serialize, Deserialize)]
pub struct ProfileInfo {
pub pubkey: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub about: Option<String>,
pub nip05_handle: Option<String>,
pub owner_pubkey: Option<String>,
/// `true` when a real kind:0 event was found on the relay; `false` for the
/// synthesized fallback returned when no metadata event exists. The
/// onboarding gate uses this to distinguish "new user with no profile" from
/// "returning user whose display_name happens to be empty".
pub has_profile_event: bool,
}
#[derive(Serialize, Deserialize)]
pub struct UserProfileSummaryInfo {
pub display_name: Option<String>,
/// Kind-0 `name` field, carried separately from `display_name` so clients
/// can match @mention text against either alias (agents and the CLI
/// resolve mentions server-side against `display_name` *or* `name`).
#[serde(default)]
pub name: Option<String>,
pub avatar_url: Option<String>,
pub nip05_handle: Option<String>,
pub owner_pubkey: Option<String>,
#[serde(default)]
pub is_agent: bool,
}
#[derive(Serialize, Deserialize)]
pub struct UsersBatchResponse {
pub profiles: HashMap<String, UserProfileSummaryInfo>,
pub missing: Vec<String>,
}
#[derive(Serialize, Deserialize)]
pub struct UserSearchResultInfo {
pub pubkey: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub nip05_handle: Option<String>,
pub owner_pubkey: Option<String>,
#[serde(default)]
pub is_agent: bool,
}
#[derive(Serialize, Deserialize)]
pub struct SearchUsersResponse {
pub users: Vec<UserSearchResultInfo>,
pub next_cursor: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct UserNoteInfo {
pub id: String,
pub pubkey: String,
pub created_at: i64,
pub content: String,
pub tags: Vec<Vec<String>>,
}
#[derive(Serialize, Deserialize)]
pub struct NoteReactionSummary {
pub note_id: String,
pub emoji: String,
pub count: usize,
pub pubkeys: Vec<String>,
}
#[derive(Serialize, Deserialize)]
pub struct UserNotesCursor {
pub before: i64,
pub before_id: String,
}
#[derive(Serialize, Deserialize)]
pub struct UserNotesResponse {
pub notes: Vec<UserNoteInfo>,
pub next_cursor: Option<UserNotesCursor>,
}
#[derive(Serialize, Deserialize)]
pub struct ChannelInfo {
pub id: String,
pub name: String,
pub channel_type: String,
pub visibility: String,
#[serde(deserialize_with = "deserialize_null_string_as_empty")]
pub description: String,
pub topic: Option<String>,
pub purpose: Option<String>,
pub member_count: i64,
#[serde(default)]
pub member_pubkeys: Vec<String>,
pub last_message_at: Option<String>,
pub archived_at: Option<String>,
#[serde(default)]
pub participants: Vec<String>,
#[serde(default)]
pub participant_pubkeys: Vec<String>,
#[serde(default = "default_true")]
pub is_member: bool,
pub ttl_seconds: Option<i32>,
pub ttl_deadline: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct ChannelDetailInfo {
pub id: String,
pub name: String,
pub channel_type: String,
pub visibility: String,
#[serde(deserialize_with = "deserialize_null_string_as_empty")]
pub description: String,
pub topic: Option<String>,
pub topic_set_by: Option<String>,
pub topic_set_at: Option<String>,
pub purpose: Option<String>,
pub purpose_set_by: Option<String>,
pub purpose_set_at: Option<String>,
pub created_by: String,
pub created_at: String,
pub updated_at: String,
pub archived_at: Option<String>,
pub member_count: i64,
pub topic_required: bool,
pub max_members: Option<i32>,
pub nip29_group_id: Option<String>,
pub ttl_seconds: Option<i32>,
pub ttl_deadline: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct ChannelMemberInfo {
pub pubkey: String,
pub role: String,
#[serde(default)]
pub is_agent: bool,
/// Optional — kind:39002 events do not carry per-member join timestamps,
/// so this is `None` when populated from a NIP-29 members event.
#[serde(default)]
pub joined_at: Option<String>,
pub display_name: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct ChannelMembersResponse {
pub members: Vec<ChannelMemberInfo>,
pub next_cursor: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct FeedItemInfo {
pub id: String,
pub kind: u32,
pub pubkey: String,
pub content: String,
pub created_at: u64,
pub channel_id: Option<String>,
pub channel_name: String,
#[serde(default)]
pub channel_type: Option<String>,
pub tags: Vec<Vec<String>>,
pub category: String,
}
#[derive(Serialize, Deserialize)]
pub struct FeedSections {
pub mentions: Vec<FeedItemInfo>,
pub needs_action: Vec<FeedItemInfo>,
pub activity: Vec<FeedItemInfo>,
pub agent_activity: Vec<FeedItemInfo>,
}
#[derive(Serialize, Deserialize)]
pub struct FeedMeta {
pub since: i64,
pub total: u64,
pub generated_at: i64,
}
#[derive(Serialize, Deserialize)]
pub struct FeedResponse {
pub feed: FeedSections,
pub meta: FeedMeta,
}
#[derive(Serialize, Deserialize)]
pub struct SearchHitInfo {
pub event_id: String,
pub content: String,
pub kind: u32,
pub pubkey: String,
pub channel_id: Option<String>,
pub channel_name: Option<String>,
pub created_at: u64,
pub score: f64,
}
#[derive(Serialize, Deserialize)]
pub struct SearchResponse {
pub hits: Vec<SearchHitInfo>,
pub found: u64,
}
#[derive(Serialize, Deserialize)]
pub struct SendChannelMessageResponse {
pub event_id: String,
pub parent_event_id: Option<String>,
pub root_event_id: Option<String>,
pub depth: u32,
pub created_at: i64,
}
#[derive(Serialize, Deserialize)]
pub struct ThreadSummary {
pub reply_count: u32,
pub descendant_count: u32,
pub last_reply_at: Option<i64>,
pub participants: Vec<String>,
}
#[derive(Serialize, Deserialize)]
pub struct ForumMessageInfo {
pub event_id: String,
pub pubkey: String,
pub sig: String,
pub content: String,
pub kind: u32,
pub created_at: i64,
pub channel_id: String,
pub tags: Vec<Vec<String>>,
#[serde(default)]
pub thread_summary: Option<ThreadSummary>,
#[serde(default)]
pub reactions: serde_json::Value,
}
#[derive(Serialize, Deserialize)]
pub struct ForumPostsResponse {
pub messages: Vec<ForumMessageInfo>,
pub next_cursor: Option<i64>,
}
#[derive(Serialize, Deserialize)]
pub struct ForumThreadReplyInfo {
pub event_id: String,
pub pubkey: String,
pub sig: String,
pub content: String,
pub kind: u32,
pub created_at: i64,
pub channel_id: String,
pub tags: Vec<Vec<String>>,
pub parent_event_id: Option<String>,
pub root_event_id: Option<String>,
pub depth: u32,
pub broadcast: bool,
#[serde(default)]
pub reactions: serde_json::Value,
}
#[derive(Serialize, Deserialize)]
pub struct ForumThreadResponse {
pub root: ForumMessageInfo,
pub replies: Vec<ForumThreadReplyInfo>,
pub total_replies: u32,
pub next_cursor: Option<String>,
}
/// Forward keyset pagination cursor for `get_thread_replies`.
///
/// Thread replies routinely share a `created_at` second (bursty threads), so
/// the cursor must carry the last reply's `event_id` as a tiebreak alongside
/// its `created_at`. A timestamp-only cursor advances past the entire tied
/// second after one page and silently drops every tied reply beyond the page
/// limit — the "missed messages" bug this read-path work fixes. The relay
/// keysets on `(event_created_at, event_id)` to match.
#[derive(Serialize, Deserialize, Clone)]
pub struct ThreadCursor {
/// `created_at` of the last reply already loaded (Unix seconds).
pub created_at: i64,
/// Hex event id of that last reply — the tiebreak within a shared second.
pub event_id: String,
}
/// Response for `get_thread_replies` — the full reply subtree under a root
/// event, fetched server-side from `thread_metadata` (NOT assembled from the
/// channel cache). `events` are raw Nostr events in chronological order;
/// `next_cursor` is the composite `(created_at, event_id)` of the last event
/// when a full page was returned, for forward keyset paging, else `None`.
#[derive(Serialize, Deserialize)]
pub struct ThreadRepliesResponse {
pub events: Vec<serde_json::Value>,
pub next_cursor: Option<ThreadCursor>,
}
/// Composite backward keyset cursor for channel-timeline paging via the bridge
/// (`get_channel_messages_before`). The relay orders `created_at DESC, id ASC`
/// and advances past a tied second with `id > before_id`, so the event id is the
/// tiebreak that lets paging escape a second denser than one WS page —
/// the case a bare `until` cursor cannot advance through.
#[derive(Serialize, Deserialize, Clone)]
pub struct ChannelPageCursor {
/// `created_at` of the last (oldest) message already loaded (Unix seconds).
pub created_at: i64,
/// Hex event id of that message — the `before_id` tiebreak within a second.
pub event_id: String,
}
/// Response for `get_channel_messages_before` — one keyset page of top-level
/// channel history, oldest-last (relay order: `created_at DESC, id ASC`).
/// `next_cursor` is the composite key of the last (oldest) event when a full
/// page was returned, else `None`.
#[derive(Serialize, Deserialize)]
pub struct ChannelMessagesPageResponse {
pub events: Vec<serde_json::Value>,
pub next_cursor: Option<ChannelPageCursor>,
}
fn deserialize_null_string_as_empty<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
}
fn default_true() -> bool {
true
}
/// Response payload for `get_channels`. When the caller supplies a hash that
/// matches the computed stable hash, `channels` is `None` so the multi-MB
/// channel list is not serialized across IPC. `last_messages` is always
/// included — it is cheap and changes frequently (every new message).
#[derive(Serialize)]
pub struct GetChannelsPayload {
pub hash: String,
/// `None` on a not-modified response (hash matched); `Some` with the full
/// sorted list otherwise.
pub channels: Option<Vec<ChannelInfo>>,
/// Map of channel id → ISO-8601 timestamp of its most recent message.
/// Empty for channels with no messages.
pub last_messages: std::collections::HashMap<String, String>,
}
// ── Social / Contact list ───────────────────────────────────────────────────
#[derive(Serialize, Deserialize)]
pub struct ContactListResponse {
pub id: String,
pub pubkey: String,
pub created_at: i64,
pub tags: Vec<Vec<String>>,
pub content: String,
}
#[derive(Serialize, Deserialize)]
pub struct ContactEntry {
pub pubkey: String,
#[serde(default)]
pub relay_url: Option<String>,
#[serde(default)]
pub petname: Option<String>,
}