mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
perf(desktop): GUI performance sweep — async offload, poll reduction, render stabilization (#1641)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc
Tyler Longwell
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr
npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm
npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
parent
11608ef675
commit
2cc0eb5396
@@ -176,7 +176,10 @@ export function usePersonasQuery() {
|
||||
queryKey: personasQueryKey,
|
||||
queryFn: listPersonas,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
// No refetchInterval: inbound relay changes to personas emit
|
||||
// `agents-data-changed`, which `useAgentsDataRefresh` coalesces into an
|
||||
// invalidate (200ms window). The 30s poll was belt-and-suspenders on top of
|
||||
// that event path — redundant disk-read IPC.
|
||||
});
|
||||
}
|
||||
|
||||
@@ -209,7 +212,16 @@ export function useRelayAgentsQuery(options?: { enabled?: boolean }) {
|
||||
queryKey: relayAgentsQueryKey,
|
||||
queryFn: listRelayAgents,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
// Relay agent profiles (kind:10100) are near-static and the backing
|
||||
// `list_relay_agents` command is an unfiltered relay query for the whole
|
||||
// profile set — mounted on ~13 always-live surfaces (channel screen,
|
||||
// members bar, mentions, sidebar, profile popovers), so a tight interval
|
||||
// re-pulls the full set app-wide. This poll is also the ONLY refresh path:
|
||||
// the `agents-data-changed` event fires only for local persona/team/managed
|
||||
// reconcile (kinds PERSONA/TEAM/MANAGED_AGENT), never for kind:10100. So we
|
||||
// keep polling but at a relaxed cadence and pause it while backgrounded.
|
||||
refetchInterval: 5 * 60_000,
|
||||
refetchIntervalInBackground: false,
|
||||
enabled: options?.enabled,
|
||||
});
|
||||
}
|
||||
@@ -222,12 +234,14 @@ export function useManagedAgentsQuery(options?: { enabled?: boolean }) {
|
||||
staleTime: 5_000,
|
||||
refetchInterval: (query) => {
|
||||
const agents = query.state.data as ManagedAgent[] | undefined;
|
||||
// Only local "running" agents need fast polling (process state can
|
||||
// change). "deployed" is static control-plane state — presence polling
|
||||
// handles the live signal for remote agents separately.
|
||||
// Only local "running" agents need polling: process state can change
|
||||
// with no relay event to signal it, so this poll is the only liveness
|
||||
// path for them. When nothing is running there IS an event path —
|
||||
// `agents-data-changed` (control-plane changes) — so the idle branch
|
||||
// drops its poll entirely rather than falling back to 30s.
|
||||
return agents?.some((agent) => agent.status === "running")
|
||||
? 5_000
|
||||
: 30_000;
|
||||
: false;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -680,7 +694,9 @@ export function useTeamsQuery() {
|
||||
queryKey: teamsQueryKey,
|
||||
queryFn: listTeams,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
// No refetchInterval: inbound relay team changes emit `agents-data-changed`
|
||||
// (handled by useAgentsDataRefresh). Same redundant-poll removal as
|
||||
// usePersonasQuery.
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,10 @@ import { useThreadReplies } from "@/features/messages/useThreadReplies";
|
||||
import { useChannelTyping } from "@/features/messages/useChannelTyping";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity";
|
||||
import {
|
||||
mergeCurrentProfileIntoLookup,
|
||||
profileLookupsEqual,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import type { RelayEvent, RespondToMode, SearchHit } from "@/shared/api/types";
|
||||
import { useChannelFind } from "@/features/search/useChannelFind";
|
||||
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
@@ -324,17 +327,6 @@ export function ChannelScreen({
|
||||
const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, {
|
||||
enabled: messageProfilePubkeys.length > 0,
|
||||
});
|
||||
const agentPubkeys = React.useMemo(() => {
|
||||
const pubkeys = new Set(knownAgentPubkeys);
|
||||
for (const [pubkey, profile] of Object.entries(
|
||||
messageProfilesQuery.data?.profiles ?? {},
|
||||
)) {
|
||||
if (profile.isAgent) {
|
||||
pubkeys.add(normalizePubkey(pubkey));
|
||||
}
|
||||
}
|
||||
return pubkeys;
|
||||
}, [knownAgentPubkeys, messageProfilesQuery.data]);
|
||||
const agentPubkeysPending =
|
||||
activeChannel?.channelType === "dm" &&
|
||||
(channelMembersQuery.isPending ||
|
||||
@@ -360,7 +352,7 @@ export function ChannelScreen({
|
||||
// Observer ingestion (frame decryption + derived active-turn liveness) is
|
||||
// owner-global — mounted once in AppShell via useAgentObserverIngestion —
|
||||
// so this screen no longer mounts its own observer/turns bridges.
|
||||
const messageProfiles = React.useMemo(() => {
|
||||
const messageProfilesRaw = React.useMemo(() => {
|
||||
const base =
|
||||
mergeCurrentProfileIntoLookup(
|
||||
messageProfilesQuery.data?.profiles,
|
||||
@@ -379,6 +371,28 @@ export function ChannelScreen({
|
||||
messageProfilesQuery.data?.profiles,
|
||||
relayAgents,
|
||||
]);
|
||||
// Stabilise the merged lookup's reference across renders when no profile
|
||||
// value changed. `messageProfilesRaw` gets a fresh identity whenever the
|
||||
// `users-batch` query re-keys — which typing churn triggers constantly — and
|
||||
// that identity flows to MessageRow's `prev.profiles === next.profiles` memo
|
||||
// check, so an unstable reference re-renders the whole timeline per keystroke.
|
||||
const messageProfilesRef = React.useRef(messageProfilesRaw);
|
||||
if (!profileLookupsEqual(messageProfilesRef.current, messageProfilesRaw)) {
|
||||
messageProfilesRef.current = messageProfilesRaw;
|
||||
}
|
||||
const messageProfiles = messageProfilesRef.current;
|
||||
// Derived from the stabilised lookup so this Set only churns when a profile
|
||||
// value actually changed — MessageRow compares `agentPubkeys` by reference,
|
||||
// and each row previously re-derived this same scan locally (removed).
|
||||
const agentPubkeys = React.useMemo(() => {
|
||||
const pubkeys = new Set(knownAgentPubkeys);
|
||||
for (const [pubkey, profile] of Object.entries(messageProfiles)) {
|
||||
if (profile.isAgent) {
|
||||
pubkeys.add(normalizePubkey(pubkey));
|
||||
}
|
||||
}
|
||||
return pubkeys;
|
||||
}, [knownAgentPubkeys, messageProfiles]);
|
||||
const personasQuery = usePersonasQuery();
|
||||
const { personaLookup, respondToLookup } = React.useMemo(() => {
|
||||
const agents = managedAgentsQuery.data ?? [];
|
||||
|
||||
@@ -22,6 +22,7 @@ type HuddleJoinInfo = {
|
||||
type VoiceInputMode = "push_to_talk" | "voice_activity";
|
||||
|
||||
const MIC_ANALYSER_UPDATE_INTERVAL_MS = 33;
|
||||
const PIPELINE_HOTSTART_INTERVAL_MS = 15_000;
|
||||
const MIC_INITIAL_NOISE_FLOOR = 0.01;
|
||||
const MIC_VOICE_GATE_ON_RMS = 0.018;
|
||||
const MIC_VOICE_GATE_OFF_RMS = 0.012;
|
||||
@@ -522,7 +523,7 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) {
|
||||
invoke("check_pipeline_hotstart").catch(() => {
|
||||
/* best-effort */
|
||||
});
|
||||
}, 5_000);
|
||||
}, PIPELINE_HOTSTART_INTERVAL_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [ephemeralChannelId]);
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ type HuddleBarProps = {
|
||||
};
|
||||
|
||||
const HUDDLE_DRAWER_EXIT_MS = 260;
|
||||
const HUDDLE_STATE_FALLBACK_INTERVAL_MS = 30_000;
|
||||
const HUDDLE_MODEL_STATUS_INTERVAL_MS = 10_000;
|
||||
const HUDDLE_REACTION_NAME_MAX = 48;
|
||||
|
||||
function isVisibleHuddleState(state: HuddleState | null) {
|
||||
@@ -212,7 +214,7 @@ export function HuddleBar({
|
||||
}
|
||||
setState(nextState);
|
||||
}, []);
|
||||
// Huddle state: event-driven + 10s fallback poll.
|
||||
// Huddle state: event-driven + slow fallback poll.
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
let unlisten: (() => void) | null = null;
|
||||
@@ -246,8 +248,12 @@ export function HuddleBar({
|
||||
else unlisten = fn;
|
||||
});
|
||||
|
||||
// Fallback: 10s poll in case events are missed
|
||||
const id = window.setInterval(() => void fetchState(), 10_000);
|
||||
// Fallback in case events are missed; keep it slow so normal huddle use is
|
||||
// event-driven and does not keep a sync IPC command warm on the main thread.
|
||||
const id = window.setInterval(
|
||||
() => void fetchState(),
|
||||
HUDDLE_STATE_FALLBACK_INTERVAL_MS,
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -293,7 +299,10 @@ export function HuddleBar({
|
||||
}
|
||||
|
||||
void pollModels();
|
||||
const id = window.setInterval(() => void pollModels(), 3_000);
|
||||
const id = window.setInterval(
|
||||
() => void pollModels(),
|
||||
HUDDLE_MODEL_STATUS_INTERVAL_MS,
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -3,6 +3,8 @@ import * as React from "react";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
|
||||
const AGENT_PUBKEY_REFRESH_INTERVAL_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Subscribe to agent TTS messages on the ephemeral huddle channel.
|
||||
* Pipes agent kind:9 messages to `speak_agent_message` on the Rust backend.
|
||||
@@ -51,7 +53,7 @@ export function useTtsSubscription(
|
||||
void loadAgentPubkeys();
|
||||
const agentRefreshId = window.setInterval(() => {
|
||||
void loadAgentPubkeys();
|
||||
}, 10_000);
|
||||
}, AGENT_PUBKEY_REFRESH_INTERVAL_MS);
|
||||
|
||||
// ── Live-only subscription ───────────────────────────────────────────
|
||||
// subscribeToChannelLive uses `since: now` — the relay never sends
|
||||
|
||||
@@ -365,7 +365,7 @@ function isCustomEmojiShortcode(emoji: string) {
|
||||
return emoji.startsWith(":") && emoji.endsWith(":");
|
||||
}
|
||||
|
||||
export function MessageActionBar({
|
||||
export const MessageActionBar = React.memo(function MessageActionBar({
|
||||
channelId,
|
||||
message,
|
||||
onDelete,
|
||||
@@ -588,4 +588,6 @@ export function MessageActionBar({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
MessageActionBar.displayName = "MessageActionBar";
|
||||
|
||||
@@ -43,6 +43,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
const DiffMessage = React.lazy(() => import("./DiffMessage"));
|
||||
const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
|
||||
|
||||
/** Stable empty fallback so rows without an agent-pubkey set keep a constant
|
||||
* reference (a fresh `new Set()` per render would defeat downstream memos). */
|
||||
const EMPTY_AGENT_PUBKEYS: ReadonlySet<string> = new Set();
|
||||
|
||||
export type ThreadDepthGuideAction = {
|
||||
active?: boolean;
|
||||
depth: number;
|
||||
@@ -149,6 +153,17 @@ export const MessageRow = React.memo(
|
||||
} = useReactionHandler(message, onToggleReaction);
|
||||
const { openReminder, activeReminderEventIds } = useRemindLater();
|
||||
const hasActiveReminder = activeReminderEventIds.has(message.id);
|
||||
const handleRemindLater = React.useCallback(
|
||||
(msg: TimelineMessage) => {
|
||||
openReminder({
|
||||
eventId: msg.id,
|
||||
channelId: channelId ?? "",
|
||||
preview: msg.body.slice(0, 100),
|
||||
authorPubkey: msg.pubkey ?? "",
|
||||
});
|
||||
},
|
||||
[channelId, openReminder],
|
||||
);
|
||||
const mentionNames = React.useMemo(
|
||||
() => resolveMentionNames(message.tags, profiles),
|
||||
[profiles, message.tags],
|
||||
@@ -157,17 +172,11 @@ export const MessageRow = React.memo(
|
||||
() => resolveMentionPubkeysByName(message.tags, profiles),
|
||||
[profiles, message.tags],
|
||||
);
|
||||
const resolvedAgentPubkeys = React.useMemo(() => {
|
||||
const pubkeys = new Set(agentPubkeys ?? []);
|
||||
|
||||
for (const [pubkey, profile] of Object.entries(profiles ?? {})) {
|
||||
if (profile.isAgent) {
|
||||
pubkeys.add(normalizePubkey(pubkey));
|
||||
}
|
||||
}
|
||||
|
||||
return pubkeys;
|
||||
}, [agentPubkeys, profiles]);
|
||||
// The agent-pubkey set is computed once by the parent (ChannelScreen)
|
||||
// from the same profile lookup and passed down already normalised — no
|
||||
// per-row rescan of `profiles` (that duplicated the parent's work in every
|
||||
// mounted row and re-ran on each profile-lookup change).
|
||||
const resolvedAgentPubkeys = agentPubkeys ?? EMPTY_AGENT_PUBKEYS;
|
||||
const profilePopoverRole =
|
||||
message.role === "bot" ||
|
||||
(message.pubkey &&
|
||||
@@ -200,11 +209,7 @@ export const MessageRow = React.memo(
|
||||
);
|
||||
const bodyOffsetClass = emojiOnly ? "mt-1" : "-mt-0.5";
|
||||
|
||||
const { channels } = useChannelNavigation();
|
||||
const channelNames = React.useMemo(
|
||||
() => channels.filter((c) => c.channelType !== "dm").map((c) => c.name),
|
||||
[channels],
|
||||
);
|
||||
const { nonDmChannelNames: channelNames } = useChannelNavigation();
|
||||
|
||||
const indentRem = getThreadReplyIndentRem(message.depth);
|
||||
const descendantGuideOffsetRem = connectDescendants
|
||||
@@ -452,14 +457,7 @@ export const MessageRow = React.memo(
|
||||
onReactionSelect={
|
||||
canToggleReactions ? handleReactionSelect : undefined
|
||||
}
|
||||
onRemindLater={(msg) => {
|
||||
openReminder({
|
||||
eventId: msg.id,
|
||||
channelId: channelId ?? "",
|
||||
preview: msg.body.slice(0, 100),
|
||||
authorPubkey: msg.pubkey ?? "",
|
||||
});
|
||||
}}
|
||||
onRemindLater={handleRemindLater}
|
||||
onReply={onReply}
|
||||
onUnfollowThread={onUnfollowThread}
|
||||
reactionErrorMessage={reactionErrorMessage}
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { TimelineMessage } from "@/features/messages/types";
|
||||
*/
|
||||
export function getConfigNudgeAuthorPubkey(
|
||||
message: Pick<TimelineMessage, "kind" | "signerPubkey">,
|
||||
resolvedAgentPubkeys: Set<string>,
|
||||
resolvedAgentPubkeys: ReadonlySet<string>,
|
||||
): string | undefined {
|
||||
if (
|
||||
message.kind === KIND_STREAM_MESSAGE &&
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { profileLookupsEqual } from "./identity.ts";
|
||||
|
||||
const summary = (over = {}) => ({
|
||||
displayName: "Ada",
|
||||
avatarUrl: "https://x/a.png",
|
||||
nip05Handle: "ada@x",
|
||||
ownerPubkey: null,
|
||||
isAgent: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
test("profileLookupsEqual: same reference is equal", () => {
|
||||
const a = { p1: summary() };
|
||||
assert.equal(profileLookupsEqual(a, a), true);
|
||||
});
|
||||
|
||||
test("profileLookupsEqual: distinct objects, identical values are equal", () => {
|
||||
assert.equal(profileLookupsEqual({ p1: summary() }, { p1: summary() }), true);
|
||||
});
|
||||
|
||||
test("profileLookupsEqual: different key count is not equal", () => {
|
||||
assert.equal(
|
||||
profileLookupsEqual({ p1: summary() }, { p1: summary(), p2: summary() }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("profileLookupsEqual: same count, different keys is not equal", () => {
|
||||
assert.equal(
|
||||
profileLookupsEqual({ p1: summary() }, { p2: summary() }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("profileLookupsEqual: a changed field is not equal", () => {
|
||||
for (const field of [
|
||||
"displayName",
|
||||
"avatarUrl",
|
||||
"nip05Handle",
|
||||
"ownerPubkey",
|
||||
"isAgent",
|
||||
]) {
|
||||
assert.equal(
|
||||
profileLookupsEqual(
|
||||
{ p1: summary() },
|
||||
{ p1: summary({ [field]: field === "isAgent" ? true : "changed" }) },
|
||||
),
|
||||
false,
|
||||
`field ${field} should break equality`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("profileLookupsEqual: two empty lookups are equal", () => {
|
||||
assert.equal(profileLookupsEqual({}, {}), true);
|
||||
});
|
||||
|
||||
// Render-count proof for the Tier-1 typing-storm fix (#1533 discipline).
|
||||
// MessageRow re-renders iff `prev.profiles === next.profiles` fails, so the
|
||||
// stabiliser's job is: hold the reference across value-equal re-derives (the
|
||||
// per-keystroke churn) and release it only on a real value change. This
|
||||
// replays the exact ChannelScreen ref idiom against a sequence of freshly
|
||||
// built lookups and asserts reference identity == render decision.
|
||||
function makeStabiliser() {
|
||||
let ref;
|
||||
let first = true;
|
||||
return (raw) => {
|
||||
if (first || !profileLookupsEqual(ref, raw)) {
|
||||
ref = raw;
|
||||
}
|
||||
first = false;
|
||||
return ref;
|
||||
};
|
||||
}
|
||||
|
||||
test("stabiliser: value-equal re-derives keep the same reference (no re-render)", () => {
|
||||
const stabilise = makeStabiliser();
|
||||
// Each entry is a fresh object identity — exactly what a users-batch re-key
|
||||
// produces on every keystroke-adjacent typing event.
|
||||
const first = stabilise({ p1: summary() });
|
||||
const churnA = stabilise({ p1: summary() });
|
||||
const churnB = stabilise({ p1: summary() });
|
||||
assert.equal(churnA, first, "value-equal churn must not swap the reference");
|
||||
assert.equal(churnB, first, "repeated churn must not swap the reference");
|
||||
});
|
||||
|
||||
test("stabiliser: a real profile change swaps the reference (re-render fires)", () => {
|
||||
const stabilise = makeStabiliser();
|
||||
const first = stabilise({ p1: summary() });
|
||||
const changed = stabilise({ p1: summary({ displayName: "Grace" }) });
|
||||
assert.notEqual(
|
||||
changed,
|
||||
first,
|
||||
"a real value change must swap the reference",
|
||||
);
|
||||
// ...and then re-stabilises around the new value.
|
||||
const held = stabilise({ p1: summary({ displayName: "Grace" }) });
|
||||
assert.equal(held, changed, "must re-stabilise around the new value");
|
||||
});
|
||||
@@ -5,6 +5,50 @@ export type UserProfileLookup = Record<string, UserProfileSummary>;
|
||||
|
||||
export { truncatePubkey };
|
||||
|
||||
/**
|
||||
* Deep-equal two profile lookups by value. Used to stabilise the merged
|
||||
* `messageProfiles` reference at the ChannelScreen boundary: the underlying
|
||||
* `users-batch` query re-keys on the full sorted pubkey set, so typing churn
|
||||
* (a transient typing-only pubkey entering/leaving the set) produces a fresh
|
||||
* lookup object identity even when no profile value actually changed. That new
|
||||
* reference fails MessageRow's `prev.profiles === next.profiles` memo check and
|
||||
* re-renders the entire timeline on every keystroke-adjacent typing event.
|
||||
* Returning the previous reference when this reports equal keeps the memo
|
||||
* intact. Consumers read profiles by pubkey value only, never treating identity
|
||||
* as a change signal, so returning the stale-but-value-identical reference is
|
||||
* safe.
|
||||
*/
|
||||
export function profileLookupsEqual(
|
||||
a: UserProfileLookup,
|
||||
b: UserProfileLookup,
|
||||
): boolean {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const aKeys = Object.keys(a);
|
||||
if (aKeys.length !== Object.keys(b).length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const key of aKeys) {
|
||||
const prev = a[key];
|
||||
const next = b[key];
|
||||
if (
|
||||
next === undefined ||
|
||||
prev.displayName !== next.displayName ||
|
||||
prev.avatarUrl !== next.avatarUrl ||
|
||||
prev.nip05Handle !== next.nip05Handle ||
|
||||
prev.ownerPubkey !== next.ownerPubkey ||
|
||||
prev.isAgent !== next.isAgent
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function getResolvedProfile(
|
||||
pubkey: string,
|
||||
profiles: UserProfileLookup | undefined,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
getGlobalNotes,
|
||||
@@ -13,6 +14,37 @@ import { allPulseTimelinesQueryKey } from "@/features/profile/hooks";
|
||||
import { withoutProjectComments } from "@/features/pulse/lib/projectComments";
|
||||
import type { UserNote, UserNotesResponse } from "@/shared/api/socialTypes";
|
||||
|
||||
function isDocumentVisible() {
|
||||
return typeof document === "undefined"
|
||||
? true
|
||||
: document.visibilityState === "visible";
|
||||
}
|
||||
|
||||
function useDocumentVisible() {
|
||||
const [visible, setVisible] = React.useState(isDocumentVisible);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
setVisible(isDocumentVisible());
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
function useVisibleRefetchInterval(intervalMs: number) {
|
||||
return useDocumentVisible() ? intervalMs : false;
|
||||
}
|
||||
|
||||
// ── Query keys ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const pulseQueryKeys = {
|
||||
@@ -31,6 +63,8 @@ export const pulseQueryKeys = {
|
||||
// ── Own notes ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function useLikedNotesQuery(pubkey?: string, enabled = true) {
|
||||
const refetchInterval = useVisibleRefetchInterval(30_000);
|
||||
|
||||
return useQuery<UserNotesResponse>({
|
||||
queryKey: pulseQueryKeys.likedNotes(pubkey ?? ""),
|
||||
queryFn: async () =>
|
||||
@@ -39,11 +73,13 @@ export function useLikedNotesQuery(pubkey?: string, enabled = true) {
|
||||
enabled: enabled && !!pubkey,
|
||||
staleTime: 15_000,
|
||||
gcTime: 5 * 60_000,
|
||||
refetchInterval: 30_000,
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMyNotesQuery(pubkey?: string) {
|
||||
const refetchInterval = useVisibleRefetchInterval(30_000);
|
||||
|
||||
return useQuery<UserNotesResponse>({
|
||||
queryKey: pulseQueryKeys.myNotes(pubkey ?? ""),
|
||||
queryFn: async () =>
|
||||
@@ -52,13 +88,15 @@ export function useMyNotesQuery(pubkey?: string) {
|
||||
enabled: !!pubkey,
|
||||
staleTime: 15_000,
|
||||
gcTime: 5 * 60_000,
|
||||
refetchInterval: 30_000,
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Timeline (notes from contacts) ─────────────────────────────────────────
|
||||
|
||||
export function useTimelineQuery(contactPubkeys: string[], enabled: boolean) {
|
||||
const refetchInterval = useVisibleRefetchInterval(30_000);
|
||||
|
||||
return useQuery<UserNotesResponse>({
|
||||
queryKey: pulseQueryKeys.timeline(contactPubkeys),
|
||||
queryFn: async () =>
|
||||
@@ -66,7 +104,7 @@ export function useTimelineQuery(contactPubkeys: string[], enabled: boolean) {
|
||||
enabled: enabled && contactPubkeys.length > 0,
|
||||
staleTime: 15_000,
|
||||
gcTime: 5 * 60_000,
|
||||
refetchInterval: 30_000,
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,6 +117,8 @@ export function usePulseReactionsQuery(
|
||||
noteIds: string[],
|
||||
currentPubkey?: string,
|
||||
) {
|
||||
const refetchInterval = useVisibleRefetchInterval(60_000);
|
||||
|
||||
return useQuery<Map<string, PulseReactionState>>({
|
||||
queryKey: pulseQueryKeys.reactions(noteIds),
|
||||
queryFn: async () => {
|
||||
@@ -100,7 +140,7 @@ export function usePulseReactionsQuery(
|
||||
enabled: noteIds.length > 0,
|
||||
staleTime: 15_000,
|
||||
gcTime: 5 * 60_000,
|
||||
refetchInterval: 60_000,
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -115,6 +155,8 @@ export function useNoteByIdQuery(noteId: string | null) {
|
||||
}
|
||||
|
||||
export function useGlobalNotesQuery(enabled: boolean) {
|
||||
const refetchInterval = useVisibleRefetchInterval(30_000);
|
||||
|
||||
return useQuery<UserNotesResponse>({
|
||||
queryKey: pulseQueryKeys.globalNotes,
|
||||
queryFn: async () =>
|
||||
@@ -122,7 +164,7 @@ export function useGlobalNotesQuery(enabled: boolean) {
|
||||
enabled,
|
||||
staleTime: 15_000,
|
||||
gcTime: 5 * 60_000,
|
||||
refetchInterval: 30_000,
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -102,12 +102,19 @@ export function useSearchResults({
|
||||
.slice(0, 5);
|
||||
}, [channelLabels, channels, debouncedQuery]);
|
||||
|
||||
const hasSearchQuery = debouncedQuery.length >= MIN_SEARCH_QUERY_LENGTH;
|
||||
const searchBackedQueriesEnabled = enabled && hasSearchQuery;
|
||||
|
||||
const userSearchQuery = useUserSearchQuery(debouncedQuery, {
|
||||
enabled: enabled && debouncedQuery.length >= MIN_SEARCH_QUERY_LENGTH,
|
||||
enabled: searchBackedQueriesEnabled,
|
||||
limit,
|
||||
});
|
||||
const managedAgentsQuery = useManagedAgentsQuery({ enabled });
|
||||
const relayAgentsQuery = useRelayAgentsQuery({ enabled });
|
||||
const managedAgentsQuery = useManagedAgentsQuery({
|
||||
enabled: searchBackedQueriesEnabled,
|
||||
});
|
||||
const relayAgentsQuery = useRelayAgentsQuery({
|
||||
enabled: searchBackedQueriesEnabled,
|
||||
});
|
||||
const managedAgentPubkeys = React.useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
|
||||
@@ -10,7 +10,7 @@ import { WorkflowDialog } from "@/features/workflows/ui/WorkflowDialog";
|
||||
import type { Channel, Workflow } from "@/shared/api/types";
|
||||
import {
|
||||
deleteWorkflow,
|
||||
getChannelWorkflows,
|
||||
getChannelsWorkflows,
|
||||
triggerWorkflow,
|
||||
} from "@/shared/api/tauriWorkflows";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -83,15 +83,21 @@ export function WorkflowsView({
|
||||
const allWorkflowsQuery = useQuery({
|
||||
queryKey: allWorkflowsQueryKey(channelIdKey),
|
||||
queryFn: async () => {
|
||||
const results: WorkflowWithChannel[] = [];
|
||||
await Promise.all(
|
||||
memberChannels.map(async (channel) => {
|
||||
const workflows = await getChannelWorkflows(channel.id);
|
||||
for (const workflow of workflows) {
|
||||
results.push({ workflow, channelName: channel.name });
|
||||
}
|
||||
}),
|
||||
// Single batched relay query for all member channels, then group by the
|
||||
// channel_id each workflow carries — replaces the per-channel fanout.
|
||||
const channelNameById = new Map(
|
||||
memberChannels.map((channel) => [channel.id, channel.name]),
|
||||
);
|
||||
const workflows = await getChannelsWorkflows(channelIds);
|
||||
const results: WorkflowWithChannel[] = [];
|
||||
for (const workflow of workflows) {
|
||||
results.push({
|
||||
workflow,
|
||||
channelName: workflow.channelId
|
||||
? (channelNameById.get(workflow.channelId) ?? "")
|
||||
: "",
|
||||
});
|
||||
}
|
||||
return results;
|
||||
},
|
||||
enabled: memberChannels.length > 0,
|
||||
|
||||
Reference in New Issue
Block a user