Files
buzz/desktop/src/features/profile/ui/useProfileInteractionActions.ts
Max Lampert 6e9942f525 fix(desktop): guard the final paint frame; trace DM entry paths
Three review findings against the tracer's honesty contract:

- The readiness -> paint frame was unguarded. `awaitDeferredCommit` checks
  age, frame gap, and visibility on every frame it drives, but the one rAF
  it queues before `record()` checked neither age nor gap, so a suspension
  in that seam (App Nap fires no visibilitychange) recorded the whole
  absence as a clean switch.
- DM actions await `open_dm` before `goChannel`, so that relay round-trip
  sat outside the measurement; Pulse's note actions used raw `navigate` and
  produced no measurement at all. Callers now capture the click anchor and
  hand it to `goChannel`, and Pulse routes through `goChannel`.
- A `navigate()` rejection left the trace it opened active, so an untraced
  re-entry could settle the failed attempt. Cancellation is by trace
  identity, so a newer same-channel attempt is never erased.

Signed-off-by: Max Lampert <maxwell@squareup.com>
2026-08-26 15:31:32 -07:00

290 lines
8.2 KiB
TypeScript

import { useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import { toast } from "sonner";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { captureSwitchTraceAnchor } from "@/shared/lib/channelSwitchPerf";
import {
channelsQueryKey,
useChannelsQuery,
useOpenDmMutation,
} from "@/features/channels/hooks";
import { useHuddle } from "@/features/huddle";
import { formatHuddleActionError } from "@/features/huddle/lib/huddleError";
import {
createOptimisticMessage,
mergeTimelineCacheMessages,
} from "@/features/messages/hooks";
import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys";
import { buildWaveMessageContent } from "@/features/messages/lib/waveMessage";
import { useProfileQuery } from "@/features/profile/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
import { sendChannelMessage } from "@/shared/api/tauri";
import type { Channel, RelayEvent } from "@/shared/api/types";
import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
export type ProfileInteractionAction = "huddle" | "message" | "wave";
type ProfileInteractionAvailability = {
huddle: boolean;
message: boolean;
wave: boolean;
};
function findCachedOneToOneDm(
channels: Channel[] | undefined,
targetPubkey: string,
currentPubkey: string | undefined,
) {
const normalizedTargetPubkey = normalizePubkey(targetPubkey);
const normalizedCurrentPubkey = currentPubkey
? normalizePubkey(currentPubkey)
: null;
return (
channels?.find((channel) => {
if (channel.channelType !== "dm") {
return false;
}
const participantPubkeys =
channel.participantPubkeys.map(normalizePubkey);
if (!participantPubkeys.includes(normalizedTargetPubkey)) {
return false;
}
const otherParticipantPubkeys = normalizedCurrentPubkey
? participantPubkeys.filter(
(participantPubkey) =>
participantPubkey !== normalizedCurrentPubkey,
)
: participantPubkeys;
return (
otherParticipantPubkeys.length === 1 &&
otherParticipantPubkeys[0] === normalizedTargetPubkey
);
}) ?? null
);
}
export function useProfileInteractionActions({
availability,
effectivePubkey,
enabled,
isBot,
isSelf,
onBeforeAction,
onClose,
viewerIsOwner,
}: {
availability?: ProfileInteractionAvailability;
effectivePubkey: string | null;
enabled: boolean;
isBot: boolean;
isSelf: boolean;
onBeforeAction?: () => void;
onClose: () => void;
viewerIsOwner: boolean | undefined;
}) {
const [pendingAction, setPendingAction] =
React.useState<ProfileInteractionAction | null>(null);
const isMountedRef = React.useRef(false);
const queryClient = useQueryClient();
const { goChannel } = useAppNavigation();
const openDmMutation = useOpenDmMutation();
const openDm = openDmMutation.mutateAsync;
const channelsQuery = useChannelsQuery();
const identityQuery = useIdentityQuery();
const { isStarting: isStartingHuddle, startHuddle } = useHuddle();
const canInteract = enabled && !isSelf && effectivePubkey !== null;
const canWave = canInteract && (availability?.wave ?? !isBot);
const canMessage =
canInteract &&
(availability?.message ?? (!isBot || viewerIsOwner === true));
const canHuddle = canInteract && (availability?.huddle ?? canMessage);
const selfProfileQuery = useProfileQuery(enabled && canWave);
React.useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const runAction = React.useCallback(
async (
action: ProfileInteractionAction,
operation: (targetPubkey: string) => Promise<void>,
) => {
if (!effectivePubkey || pendingAction !== null) {
return;
}
onBeforeAction?.();
setPendingAction(action);
try {
await operation(effectivePubkey);
} catch (error) {
if (action === "huddle") {
toast.error(formatHuddleActionError(error, "start"));
} else {
toast.error(
error instanceof Error
? error.message
: action === "wave"
? "Couldn't send the wave."
: "Couldn't open the direct message.",
);
}
} finally {
if (isMountedRef.current) {
setPendingAction(null);
}
}
},
[effectivePubkey, onBeforeAction, pendingAction],
);
const handleMessage = React.useCallback(() => {
if (!canMessage) {
return;
}
// Anchor before awaiting open_dm: that relay round-trip is felt click
// latency and belongs inside the switch measurement.
const traceStartedAt = captureSwitchTraceAnchor();
void runAction("message", async (targetPubkey) => {
const dm = await openDm({ pubkeys: [targetPubkey] });
await goChannel(dm.id, { traceStartedAt });
if (isMountedRef.current) {
onClose();
}
});
}, [canMessage, goChannel, onClose, openDm, runAction]);
const handleHuddle = React.useCallback(() => {
if (!canHuddle || isStartingHuddle) {
return;
}
void runAction("huddle", async (targetPubkey) => {
const dm = await openDm({ pubkeys: [targetPubkey] });
await goChannel(dm.id);
await startHuddle(dm.id, isBot ? [targetPubkey] : []);
await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
if (isMountedRef.current) {
onClose();
}
});
}, [
canHuddle,
goChannel,
isBot,
isStartingHuddle,
onClose,
openDm,
queryClient,
runAction,
startHuddle,
]);
const handleWave = React.useCallback(() => {
if (!canWave) {
return;
}
void runAction("wave", async (targetPubkey) => {
const identity = identityQuery.data;
if (!identity) {
throw new Error("No identity is available to send messages.");
}
const dm =
findCachedOneToOneDm(
channelsQuery.data,
targetPubkey,
identity.pubkey,
) ?? (await openDm({ pubkeys: [targetPubkey] }));
const senderName =
selfProfileQuery.data?.displayName?.trim() ||
identity.displayName.trim() ||
truncatePubkey(identity.pubkey);
const content = buildWaveMessageContent(senderName);
const queryKey = channelMessagesKey(dm.id);
await queryClient.cancelQueries({ queryKey });
const previousMessages =
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
const optimisticMessage = createOptimisticMessage(
dm.id,
content,
identity,
previousMessages,
);
queryClient.setQueryData<RelayEvent[]>(
queryKey,
mergeTimelineCacheMessages(previousMessages, optimisticMessage),
);
try {
await goChannel(dm.id);
if (isMountedRef.current) {
onClose();
}
const result = await sendChannelMessage(dm.id, content);
queryClient.setQueryData<RelayEvent[]>(queryKey, (current = []) =>
mergeTimelineCacheMessages(current, {
id: result.eventId,
localKey: optimisticMessage.id,
pubkey: identity.pubkey,
created_at: result.createdAt,
kind: KIND_STREAM_MESSAGE,
tags: [
["h", dm.id],
["p", identity.pubkey],
],
content: content.trim(),
sig: "",
}),
);
} catch (error) {
queryClient.setQueryData<RelayEvent[]>(queryKey, (current = []) =>
current.filter(
(message) =>
message.id !== optimisticMessage.id &&
message.localKey !== optimisticMessage.localKey,
),
);
throw error;
}
});
}, [
canWave,
channelsQuery.data,
goChannel,
identityQuery.data,
onClose,
openDm,
queryClient,
runAction,
selfProfileQuery.data?.displayName,
]);
return {
canHuddle,
canMessage,
canWave,
handleHuddle,
handleMessage,
handleWave,
isOpeningDm: openDmMutation.isPending,
isStartingHuddle,
pendingAction,
};
}