mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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>
395 lines
12 KiB
Rust
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>,
|
|
}
|