From 80145544a5f5dbbe16bcb1b89b0fa6a4a3cb1adc Mon Sep 17 00:00:00 2001 From: Simo Date: Tue, 21 Apr 2026 09:14:56 +0300 Subject: [PATCH 01/15] wip Signed-off-by: Simo --- .../drops/WaveDropActionsAddReaction.test.tsx | 19 + .../waves/drops/WaveDropReactions.test.tsx | 19 + .../monitoring/dropReactionMonitoring.test.ts | 332 +++++++++ .../drops/WaveDropActionsAddReaction.tsx | 5 +- .../waves/drops/WaveDropActionsQuickReact.tsx | 5 +- components/waves/drops/WaveDropReactions.tsx | 45 ++ contexts/wave/hooks/useWaveRealtimeUpdater.ts | 12 + hooks/drops/useDropReaction.ts | 63 +- services/api/common-api.ts | 4 +- utils/monitoring/dropReactionMonitoring.ts | 654 ++++++++++++++++++ 10 files changed, 1150 insertions(+), 8 deletions(-) create mode 100644 __tests__/utils/monitoring/dropReactionMonitoring.test.ts create mode 100644 utils/monitoring/dropReactionMonitoring.ts diff --git a/__tests__/components/waves/drops/WaveDropActionsAddReaction.test.tsx b/__tests__/components/waves/drops/WaveDropActionsAddReaction.test.tsx index 84e16961d9..7843f167b6 100644 --- a/__tests__/components/waves/drops/WaveDropActionsAddReaction.test.tsx +++ b/__tests__/components/waves/drops/WaveDropActionsAddReaction.test.tsx @@ -47,6 +47,25 @@ jest.mock("@/services/api/common-api", () => ({ commonApiPost: jest.fn(() => Promise.resolve({})), })); +jest.mock("@sentry/nextjs", () => ({ + __esModule: true, + addBreadcrumb: jest.fn(), + withScope: jest.fn((callback: (scope: any) => void) => { + const scope = { + setLevel: jest.fn(), + setFingerprint: jest.fn(), + setTag: jest.fn(), + setExtras: jest.fn(), + }; + callback(scope); + }), + captureException: jest.fn(), +})); + +jest.mock("@/services/websocket/useWebSocketMessage", () => ({ + useWebsocketStatus: jest.fn(() => "connected"), +})); + jest.mock("@/components/mobile-wrapper-dialog/MobileWrapperDialog", () => ({ __esModule: true, default: (props: any) => mobileWrapperDialogMock(props), diff --git a/__tests__/components/waves/drops/WaveDropReactions.test.tsx b/__tests__/components/waves/drops/WaveDropReactions.test.tsx index 6e650feab5..cddc7b305c 100644 --- a/__tests__/components/waves/drops/WaveDropReactions.test.tsx +++ b/__tests__/components/waves/drops/WaveDropReactions.test.tsx @@ -28,6 +28,25 @@ jest.mock("@/services/api/common-api", () => ({ commonApiDelete: jest.fn(), })); +jest.mock("@sentry/nextjs", () => ({ + __esModule: true, + addBreadcrumb: jest.fn(), + withScope: jest.fn((callback: (scope: any) => void) => { + const scope = { + setLevel: jest.fn(), + setFingerprint: jest.fn(), + setTag: jest.fn(), + setExtras: jest.fn(), + }; + callback(scope); + }), + captureException: jest.fn(), +})); + +jest.mock("@/services/websocket/useWebSocketMessage", () => ({ + useWebsocketStatus: jest.fn(() => "connected"), +})); + jest.mock("@/hooks/useIsTouchDevice", () => ({ __esModule: true, default: jest.fn(() => false), diff --git a/__tests__/utils/monitoring/dropReactionMonitoring.test.ts b/__tests__/utils/monitoring/dropReactionMonitoring.test.ts new file mode 100644 index 0000000000..26e93ddd51 --- /dev/null +++ b/__tests__/utils/monitoring/dropReactionMonitoring.test.ts @@ -0,0 +1,332 @@ +import * as Sentry from "@sentry/nextjs"; +import { + __resetDropReactionMonitoringForTests, + beginReactionMutation, + deriveReactionAction, + recordReactionOptimisticApplied, + recordReactionRealtimeReconciliation, + recordReactionRequestFailed, + recordReactionRequestSent, + recordReactionRequestSucceeded, + recordReactionRollbackApplied, +} from "@/utils/monitoring/dropReactionMonitoring"; +import { WebSocketStatus } from "@/services/websocket/WebSocketTypes"; + +jest.mock("@sentry/nextjs", () => ({ + __esModule: true, + addBreadcrumb: jest.fn(), + withScope: jest.fn((callback: (scope: any) => void) => { + const scope = { + setLevel: jest.fn(), + setFingerprint: jest.fn(), + setTag: jest.fn(), + setExtras: jest.fn(), + }; + callback(scope); + }), + captureException: jest.fn(), +})); + +describe("dropReactionMonitoring", () => { + const addBreadcrumbMock = Sentry.addBreadcrumb as jest.Mock; + const withScopeMock = Sentry.withScope as jest.Mock; + const captureExceptionMock = Sentry.captureException as jest.Mock; + let dateNowSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + __resetDropReactionMonitoringForTests(); + dateNowSpy = jest.spyOn(Date, "now").mockReturnValue(1_000); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("derives reaction actions correctly", () => { + expect(deriveReactionAction(null, ":smile:")).toBe("add"); + expect(deriveReactionAction(":wave:", null)).toBe("remove"); + expect(deriveReactionAction(":wave:", ":smile:")).toBe("replace"); + }); + + it("records breadcrumbs for a successful reaction request", () => { + const mutation = beginReactionMutation({ + dropId: "drop-1", + waveId: "wave-1", + source: "quick-react", + action: "add", + previousReaction: null, + intendedReaction: ":smile:", + optimisticReaction: ":smile:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + + dateNowSpy.mockReturnValue(1_100); + recordReactionOptimisticApplied(mutation); + recordReactionRequestSent(mutation, { + endpoint: "drops/drop-1/reaction", + method: "POST", + }); + + dateNowSpy.mockReturnValue(1_250); + recordReactionRequestSucceeded(mutation); + + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: "reaction.intent", + }) + ); + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: "reaction.optimistic_applied", + }) + ); + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: "reaction.request_sent", + }) + ); + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: "reaction.request_succeeded", + data: expect.objectContaining({ + latency_ms: 150, + }), + }) + ); + expect(captureExceptionMock).not.toHaveBeenCalled(); + }); + + it("captures a classified failure event for auth errors", () => { + const mutation = beginReactionMutation({ + dropId: "drop-2", + waveId: "wave-1", + source: "picker", + action: "replace", + previousReaction: ":wave:", + intendedReaction: ":smile:", + optimisticReaction: ":smile:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + + recordReactionRequestSent(mutation, { + endpoint: "drops/drop-2/reaction", + method: "POST", + }); + + const error = Object.assign(new Error("Unauthorized"), { + status: 401, + }); + + dateNowSpy.mockReturnValue(1_200); + recordReactionRequestFailed(mutation, error); + recordReactionRollbackApplied(mutation); + + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: "reaction.request_failed", + data: expect.objectContaining({ + status_code: 401, + error_kind: "auth", + }), + }) + ); + expect(withScopeMock).toHaveBeenCalled(); + expect(captureExceptionMock).toHaveBeenCalledWith(error); + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: "reaction.rollback_applied", + }) + ); + }); + + it("captures an out-of-order anomaly when an older mutation resolves last", () => { + const olderMutation = beginReactionMutation({ + dropId: "drop-3", + waveId: "wave-1", + source: "quick-react", + action: "add", + previousReaction: null, + intendedReaction: ":wave:", + optimisticReaction: ":wave:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + + const newerMutation = beginReactionMutation({ + dropId: "drop-3", + waveId: "wave-1", + source: "quick-react", + action: "replace", + previousReaction: ":wave:", + intendedReaction: ":smile:", + optimisticReaction: ":smile:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + + void newerMutation; + + recordReactionRequestSent(olderMutation, { + endpoint: "drops/drop-3/reaction", + method: "POST", + }); + + dateNowSpy.mockReturnValue(1_350); + recordReactionRequestSucceeded(olderMutation); + + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: "reaction.response_superseded", + data: expect.objectContaining({ + superseded_by_mutation_id: expect.any(String), + }), + }) + ); + expect(captureExceptionMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Reaction response superseded by newer mutation", + }) + ); + }); + + it("captures reconciliation mismatch only after apparent success", () => { + const mutation = beginReactionMutation({ + dropId: "drop-4", + waveId: "wave-1", + source: "chip", + action: "replace", + previousReaction: ":wave:", + intendedReaction: ":smile:", + optimisticReaction: ":smile:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + + recordReactionRequestSent(mutation, { + endpoint: "drops/drop-4/reaction", + method: "POST", + }); + + dateNowSpy.mockReturnValue(1_100); + recordReactionRequestSucceeded(mutation); + + dateNowSpy.mockReturnValue(1_500); + recordReactionRealtimeReconciliation({ + drop: { + id: "drop-4", + wave: { id: "wave-1" }, + context_profile_context: { + reaction: ":wave:", + } as any, + }, + websocketStatus: WebSocketStatus.CONNECTED, + }); + + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: "reaction.optimistic_reverted", + data: expect.objectContaining({ + server_reaction: ":wave:", + }), + }) + ); + expect(captureExceptionMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Reaction optimistic state disagreed with canonical state", + }) + ); + }); + + it("adds a breadcrumb only when realtime reconciliation matches intent", () => { + const mutation = beginReactionMutation({ + dropId: "drop-5", + waveId: "wave-1", + source: "chip", + action: "add", + previousReaction: null, + intendedReaction: ":smile:", + optimisticReaction: ":smile:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + + recordReactionRequestSent(mutation, { + endpoint: "drops/drop-5/reaction", + method: "POST", + }); + + dateNowSpy.mockReturnValue(1_100); + recordReactionRequestSucceeded(mutation); + + dateNowSpy.mockReturnValue(1_300); + recordReactionRealtimeReconciliation({ + drop: { + id: "drop-5", + wave: { id: "wave-1" }, + context_profile_context: { + reaction: ":smile:", + } as any, + }, + websocketStatus: WebSocketStatus.CONNECTED, + }); + + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: "reaction.realtime_reconciled", + }) + ); + expect(captureExceptionMock).not.toHaveBeenCalled(); + }); + + it("dedupes repeated identical failure events within 60 seconds", () => { + const firstMutation = beginReactionMutation({ + dropId: "drop-6", + waveId: "wave-1", + source: "picker", + action: "add", + previousReaction: null, + intendedReaction: ":smile:", + optimisticReaction: ":smile:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + recordReactionRequestSent(firstMutation, { + endpoint: "drops/drop-6/reaction", + method: "POST", + }); + + const secondMutation = beginReactionMutation({ + dropId: "drop-6", + waveId: "wave-1", + source: "picker", + action: "add", + previousReaction: null, + intendedReaction: ":smile:", + optimisticReaction: ":smile:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + recordReactionRequestSent(secondMutation, { + endpoint: "drops/drop-6/reaction", + method: "POST", + }); + + const networkError = new TypeError("Failed to fetch"); + + dateNowSpy.mockReturnValue(1_100); + recordReactionRequestFailed(firstMutation, networkError); + dateNowSpy.mockReturnValue(1_200); + recordReactionRequestFailed(secondMutation, networkError); + + expect(captureExceptionMock).toHaveBeenCalledTimes(2); + expect(captureExceptionMock.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + message: "Reaction response superseded by newer mutation", + }) + ); + expect(captureExceptionMock.mock.calls[1]?.[0]).toBe(networkError); + }); +}); diff --git a/components/waves/drops/WaveDropActionsAddReaction.tsx b/components/waves/drops/WaveDropActionsAddReaction.tsx index 1b9c3a57ef..2c2f704292 100644 --- a/components/waves/drops/WaveDropActionsAddReaction.tsx +++ b/components/waves/drops/WaveDropActionsAddReaction.tsx @@ -16,7 +16,10 @@ const WaveDropActionsAddReaction: React.FC<{ readonly onAddReaction?: (() => void) | undefined; readonly dialogZIndexClassName?: string | undefined; }> = ({ drop, isMobile = false, onAddReaction, dialogZIndexClassName }) => { - const { react, canReact } = useDropReaction(drop, onAddReaction); + const { react, canReact } = useDropReaction(drop, { + source: "picker", + onSuccess: onAddReaction, + }); const [showPicker, setShowPicker] = useState(false); const buttonRef = useRef(null); const pickerContainerRef = useRef(null); diff --git a/components/waves/drops/WaveDropActionsQuickReact.tsx b/components/waves/drops/WaveDropActionsQuickReact.tsx index ea00b74545..f7adac3a93 100644 --- a/components/waves/drops/WaveDropActionsQuickReact.tsx +++ b/components/waves/drops/WaveDropActionsQuickReact.tsx @@ -21,7 +21,10 @@ const WaveDropActionsQuickReact: React.FC<{ readonly isMobile?: boolean; readonly onReacted?: () => void; }> = ({ drop, isMobile = false, onReacted }) => { - const { react, canReact } = useDropReaction(drop, onReacted); + const { react, canReact } = useDropReaction(drop, { + source: "quick-react", + onSuccess: onReacted, + }); // Subscribe to localStorage changes (hydration-safe) const reactionSnapshot = useSyncExternalStore( diff --git a/components/waves/drops/WaveDropReactions.tsx b/components/waves/drops/WaveDropReactions.tsx index cc895e7019..e591454705 100644 --- a/components/waves/drops/WaveDropReactions.tsx +++ b/components/waves/drops/WaveDropReactions.tsx @@ -14,6 +14,7 @@ import { DropSize } from "@/helpers/waves/drop.helpers"; import useIsTouchDevice from "@/hooks/useIsTouchDevice"; import useLongPressInteraction from "@/hooks/useLongPressInteraction"; import { commonApiDelete, commonApiPost } from "@/services/api/common-api"; +import { useWebsocketStatus } from "@/services/websocket/useWebSocketMessage"; import clsx from "clsx"; import Image from "next/image"; import Link from "next/link"; @@ -31,6 +32,15 @@ import { removeUserFromReactions, toProfileMin, } from "./reaction-utils"; +import { + beginReactionMutation, + deriveReactionAction, + recordReactionOptimisticApplied, + recordReactionRequestFailed, + recordReactionRequestSent, + recordReactionRequestSucceeded, + recordReactionRollbackApplied, +} from "@/utils/monitoring/dropReactionMonitoring"; import styles from "./WaveDropReactions.module.scss"; import WaveDropReactionsDetailDialog from "./WaveDropReactionsDetailDialog"; @@ -85,6 +95,7 @@ function WaveDropReaction({ const { setToast, connectedProfile } = useAuth(); const { emojiMap, findNativeEmoji } = useEmoji(); const { applyOptimisticDropUpdate } = useMyStream(); + const websocketStatus = useWebsocketStatus(); const rollbackRef = useRef<(() => void) | null>(null); const canReact = Boolean(connectedProfile?.handle); @@ -326,10 +337,27 @@ function WaveDropReaction({ return; } + const intendedReaction = selected ? null : reaction.reaction; + const mutation = beginReactionMutation({ + dropId: drop.id, + waveId, + source: "chip", + action: deriveReactionAction( + drop.context_profile_context?.reaction ?? null, + intendedReaction + ), + previousReaction: drop.context_profile_context?.reaction ?? null, + intendedReaction, + optimisticReaction: intendedReaction, + profileId: connectedProfile?.id ?? null, + websocketStatus, + }); + setSelected((s) => !s); setTotal((n) => Math.max(0, n + (selected ? -1 : 1))); applyOptimisticReactionChange(!selected); + recordReactionOptimisticApplied(mutation); if (!selected) { recordReaction(reaction.reaction); @@ -339,16 +367,28 @@ function WaveDropReaction({ const body = { reaction: reaction.reaction }; const endpoint = `drops/${drop.id}/reaction`; if (selected) { + recordReactionRequestSent(mutation, { + endpoint, + method: "DELETE", + }); await commonApiDelete({ endpoint, + errorMode: "structured", }); } else { + recordReactionRequestSent(mutation, { + endpoint, + method: "POST", + }); await commonApiPost({ endpoint, body, + errorMode: "structured", }); } + recordReactionRequestSucceeded(mutation); } catch (error) { + recordReactionRequestFailed(mutation, error); let msg = selected ? "Error removing reaction" : "Error adding reaction"; if (typeof error === "string") msg = error; setToast({ message: msg, type: "error" }); @@ -356,17 +396,22 @@ function WaveDropReaction({ setSelected((s) => !s); setTotal((n) => Math.max(0, n + (selected ? 1 : -1))); rollbackRef.current?.(); + recordReactionRollbackApplied(mutation); rollbackRef.current = null; } rollbackRef.current = null; }, [ applyOptimisticReactionChange, canReact, + connectedProfile?.id, drop.id, + drop.context_profile_context?.reaction, longPressTriggered, reaction.reaction, selected, setToast, + waveId, + websocketStatus, ]); const tooltipProfiles = useMemo(() => { diff --git a/contexts/wave/hooks/useWaveRealtimeUpdater.ts b/contexts/wave/hooks/useWaveRealtimeUpdater.ts index 8c6f0d4fea..3ec3c58af8 100644 --- a/contexts/wave/hooks/useWaveRealtimeUpdater.ts +++ b/contexts/wave/hooks/useWaveRealtimeUpdater.ts @@ -14,6 +14,8 @@ import { useCallback, useContext, useEffect, useRef } from "react"; import { useWaveEligibility } from "../WaveEligibilityContext"; import type { WaveDataStoreUpdater } from "./types"; import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { WebSocketStatus } from "@/services/websocket/WebSocketTypes"; +import { recordReactionRealtimeReconciliation } from "@/utils/monitoring/dropReactionMonitoring"; interface UseWaveRealtimeUpdaterProps extends WaveDataStoreUpdater { readonly activeWaveId: string | null; @@ -183,6 +185,16 @@ export function useWaveRealtimeUpdater({ endpoint: `drops/${drop.id}`, }); if (apiDrop) { + if (type === ProcessIncomingDropType.DROP_REACTION_UPDATE) { + recordReactionRealtimeReconciliation({ + drop: { + id: apiDrop.id, + wave: { id: apiDrop.wave.id }, + context_profile_context: apiDrop.context_profile_context, + }, + websocketStatus: WebSocketStatus.CONNECTED, + }); + } updateData({ key: waveId, drops: [ diff --git a/hooks/drops/useDropReaction.ts b/hooks/drops/useDropReaction.ts index ac93d7a880..0f62b7cac5 100644 --- a/hooks/drops/useDropReaction.ts +++ b/hooks/drops/useDropReaction.ts @@ -9,6 +9,7 @@ import { recordReaction } from "@/helpers/reactions/reactionHistory"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { DropSize } from "@/helpers/waves/drop.helpers"; import { commonApiDelete, commonApiPost } from "@/services/api/common-api"; +import { useWebsocketStatus } from "@/services/websocket/useWebSocketMessage"; import { useCallback, useRef } from "react"; import { cloneReactionEntries, @@ -16,19 +17,37 @@ import { removeUserFromReactions, toProfileMin, } from "@/components/waves/drops/reaction-utils"; +import { + beginReactionMutation, + deriveReactionAction, + recordReactionOptimisticApplied, + recordReactionRequestFailed, + recordReactionRequestSent, + recordReactionRequestSucceeded, + recordReactionRollbackApplied, + type ReactionSource, +} from "@/utils/monitoring/dropReactionMonitoring"; interface UseDropReactionResult { readonly react: (reactionCode: string) => Promise; readonly canReact: boolean; } +interface UseDropReactionOptions { + readonly source?: ReactionSource | undefined; + readonly onSuccess?: (() => void) | undefined; +} + export function useDropReaction( drop: ExtendedDrop, - onSuccess?: () => void + options?: UseDropReactionOptions ): UseDropReactionResult { const { setToast, connectedProfile } = useAuth(); const { applyOptimisticDropUpdate } = useMyStream(); + const websocketStatus = useWebsocketStatus(); const rollbackRef = useRef<(() => void) | null>(null); + const source = options?.source ?? "picker"; + const onSuccess = options?.onSuccess; const canReact = !drop.id.startsWith("temp-"); @@ -120,11 +139,25 @@ export function useDropReaction( if (!canReact) return; const isRemoving = reactionCode === contextProfileContext?.reaction; + const intendedReaction = isRemoving ? null : reactionCode; + const mutation = beginReactionMutation({ + dropId, + waveId, + source, + action: deriveReactionAction( + contextProfileContext?.reaction ?? null, + intendedReaction + ), + previousReaction: contextProfileContext?.reaction ?? null, + intendedReaction, + optimisticReaction: intendedReaction, + profileId: connectedProfile?.id ?? null, + websocketStatus, + }); rollbackRef.current?.(); - rollbackRef.current = applyOptimisticReaction( - isRemoving ? null : reactionCode - ); + rollbackRef.current = applyOptimisticReaction(intendedReaction); + recordReactionOptimisticApplied(mutation); if (!isRemoving) { recordReaction(reactionCode); @@ -133,16 +166,30 @@ export function useDropReaction( try { const endpoint = `drops/${drop.id}/reaction`; if (isRemoving) { - await commonApiDelete({ endpoint }); + recordReactionRequestSent(mutation, { + endpoint, + method: "DELETE", + }); + await commonApiDelete({ + endpoint, + errorMode: "structured", + }); } else { + recordReactionRequestSent(mutation, { + endpoint, + method: "POST", + }); await commonApiPost({ endpoint, body: { reaction: reactionCode }, + errorMode: "structured", }); } + recordReactionRequestSucceeded(mutation); rollbackRef.current = null; onSuccess?.(); } catch (error) { + recordReactionRequestFailed(mutation, error); let errorMessage = isRemoving ? "Error removing reaction" : "Error adding reaction"; @@ -151,16 +198,22 @@ export function useDropReaction( } setToast({ message: errorMessage, type: "error" }); rollbackRef.current?.(); + recordReactionRollbackApplied(mutation); rollbackRef.current = null; } }, [ canReact, applyOptimisticReaction, + connectedProfile?.id, contextProfileContext?.reaction, drop.id, + dropId, setToast, onSuccess, + source, + waveId, + websocketStatus, ] ); diff --git a/services/api/common-api.ts b/services/api/common-api.ts index 51fcc54f16..cb6947a84a 100644 --- a/services/api/common-api.ts +++ b/services/api/common-api.ts @@ -388,6 +388,7 @@ export const commonApiPostWithoutBodyAndResponse = async (param: { export const commonApiDelete = async (param: { endpoint: string; headers?: Record | undefined; + errorMode?: ApiErrorMode | undefined; }): Promise => { const url = buildUrl(param.endpoint); @@ -397,7 +398,8 @@ export const commonApiDelete = async (param: { getHeaders(param.headers), undefined, undefined, - false + false, + param.errorMode ?? "legacy-string" ); }; diff --git a/utils/monitoring/dropReactionMonitoring.ts b/utils/monitoring/dropReactionMonitoring.ts new file mode 100644 index 0000000000..5a12aa4f87 --- /dev/null +++ b/utils/monitoring/dropReactionMonitoring.ts @@ -0,0 +1,654 @@ +"use client"; + +import type { ApiDrop } from "@/generated/models/ApiDrop"; +import { WebSocketStatus } from "@/services/websocket/WebSocketTypes"; +import * as Sentry from "@sentry/nextjs"; + +const RECONCILIATION_WINDOW_MS = 15_000; +const DEDUPE_WINDOW_MS = 60_000; +const MUTATION_RETENTION_MS = 5 * 60_000; +const REACTION_FEATURE = "drop-reaction"; +const REACTION_REQUEST_OPERATION = "reaction-request"; +const REACTION_ANOMALY_OPERATION = "reaction-anomaly"; +const ANOMALY_OUT_OF_ORDER = "out-of-order"; +const ANOMALY_OPTIMISTIC_REVERTED = "optimistic-reverted"; +const ANOMALY_REVERTED_AFTER_SUCCESS = "reverted-after-success"; + +export type ReactionSource = "quick-react" | "picker" | "chip"; +type ReactionAction = "add" | "remove" | "replace"; +type ReactionErrorKind = + | "network" + | "auth" + | "rate-limit" + | "server" + | "endpoint-contract"; + +interface ReactionMutationContext { + readonly mutationId: string; + readonly dropMutationSeq: number; + readonly dropId: string; + readonly waveId: string; + readonly source: ReactionSource; + readonly action: ReactionAction; + readonly previousReaction: string | null; + readonly intendedReaction: string | null; + readonly optimisticReaction: string | null; + readonly profileId: string | null; + readonly startedAt: number; + readonly pathname: string | null; + readonly visibilityState: string | null; + readonly online: boolean | null; + readonly websocketStatus: WebSocketStatus | null; + endpoint?: string | null; + method?: string | null; + requestSentAt?: number | null; + apiSucceededAt?: number | null; + apiFailedAt?: number | null; + failureCaptured?: boolean; + supersededByMutationId?: string | null; +} + +const latestMutationIdByDrop = new Map(); +const dropMutationSeqByDrop = new Map(); +const mutationContextById = new Map(); +const dedupeEventAtByKey = new Map(); + +function pruneState(now: number): void { + for (const [key, timestamp] of dedupeEventAtByKey.entries()) { + if (now - timestamp > DEDUPE_WINDOW_MS) { + dedupeEventAtByKey.delete(key); + } + } + + for (const [mutationId, context] of mutationContextById.entries()) { + if (now - context.startedAt <= MUTATION_RETENTION_MS) { + continue; + } + + mutationContextById.delete(mutationId); + if (latestMutationIdByDrop.get(context.dropId) === mutationId) { + latestMutationIdByDrop.delete(context.dropId); + } + } +} + +function shouldCaptureEvent(key: string, now: number): boolean { + const lastCapturedAt = dedupeEventAtByKey.get(key); + if ( + typeof lastCapturedAt === "number" && + now - lastCapturedAt < DEDUPE_WINDOW_MS + ) { + return false; + } + + dedupeEventAtByKey.set(key, now); + return true; +} + +function createMutationId(): string { + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { + return crypto.randomUUID(); + } + + return `reaction-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function getCurrentPathname(): string | null { + if (typeof window === "undefined") { + return null; + } + return window.location.pathname || null; +} + +function getVisibilityState(): string | null { + if (typeof document === "undefined") { + return null; + } + return document.visibilityState; +} + +function getOnlineStatus(): boolean | null { + if (typeof navigator === "undefined") { + return null; + } + return navigator.onLine; +} + +function toNullableReaction(value: string | null | undefined): string | null { + return typeof value === "string" ? value : null; +} + +function toWebsocketStatus( + value: WebSocketStatus | string | null | undefined +): WebSocketStatus | null { + if ( + value === WebSocketStatus.CONNECTED || + value === WebSocketStatus.CONNECTING || + value === WebSocketStatus.DISCONNECTED + ) { + return value; + } + + return null; +} + +function addReactionBreadcrumb( + message: string, + context: ReactionMutationContext, + data: Record = {}, + level: "info" | "warning" | "error" = "info" +): void { + Sentry.addBreadcrumb({ + category: "reactions", + level, + message, + data: { + mutation_id: context.mutationId, + drop_mutation_seq: context.dropMutationSeq, + drop_id: context.dropId, + wave_id: context.waveId, + source: context.source, + action: context.action, + previous_reaction: context.previousReaction, + intended_reaction: context.intendedReaction, + optimistic_reaction: context.optimisticReaction, + profile_id: context.profileId ?? undefined, + pathname: context.pathname ?? undefined, + visibility_state: context.visibilityState ?? undefined, + online: context.online ?? undefined, + websocket_status: context.websocketStatus ?? undefined, + endpoint: context.endpoint ?? undefined, + method: context.method ?? undefined, + ...data, + }, + }); +} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + if (typeof error === "object" && error) { + const typedError = error as { + message?: unknown; + error?: unknown; + }; + if (typeof typedError.message === "string") { + return typedError.message; + } + if (typeof typedError.error === "string") { + return typedError.error; + } + } + return String(error); +} + +function toCaptureExceptionInput(error: unknown): Error { + if (error instanceof Error) { + return error; + } + return new Error(toErrorMessage(error)); +} + +function parseStatusCode(status: unknown): number | null { + if (typeof status === "number" && Number.isFinite(status)) { + return status; + } + + if (typeof status === "string") { + const parsed = Number.parseInt(status, 10); + return Number.isNaN(parsed) ? null : parsed; + } + + return null; +} + +function extractErrorStatusCode(error: unknown): number | null { + if (error === null || typeof error !== "object") { + return null; + } + + const typedError = error as { + status?: unknown; + code?: unknown; + response?: { + status?: unknown; + }; + cause?: { + status?: unknown; + code?: unknown; + response?: { + status?: unknown; + }; + }; + }; + + return ( + parseStatusCode(typedError.status) ?? + parseStatusCode(typedError.response?.status) ?? + parseStatusCode(typedError.code) ?? + parseStatusCode(typedError.cause?.status) ?? + parseStatusCode(typedError.cause?.response?.status) ?? + parseStatusCode(typedError.cause?.code) + ); +} + +function isNetworkError(error: unknown): boolean { + if (error instanceof TypeError) { + return true; + } + + const normalizedMessage = toErrorMessage(error).toLowerCase(); + return ( + normalizedMessage.includes("failed to fetch") || + normalizedMessage.includes("load failed") || + normalizedMessage.includes("networkerror") || + normalizedMessage.includes("network error") || + normalizedMessage.includes("network request failed") + ); +} + +function classifyReactionError(error: unknown): { + statusCode: number | null; + errorKind: ReactionErrorKind; +} { + const statusCode = extractErrorStatusCode(error); + + if (statusCode === 401 || statusCode === 403) { + return { statusCode, errorKind: "auth" }; + } + + if (statusCode === 429) { + return { statusCode, errorKind: "rate-limit" }; + } + + if (statusCode === 404 || statusCode === 405) { + return { statusCode, errorKind: "endpoint-contract" }; + } + + if (typeof statusCode === "number" && statusCode >= 500) { + return { statusCode, errorKind: "server" }; + } + + if (isNetworkError(error)) { + return { statusCode, errorKind: "network" }; + } + + return { statusCode, errorKind: "server" }; +} + +function captureReactionEvent({ + error, + level, + fingerprint, + tags, + extra, +}: { + error: Error; + level: "warning" | "error"; + fingerprint: string[]; + tags: Record; + extra: Record; +}): void { + Sentry.withScope((scope) => { + scope.setLevel(level); + scope.setFingerprint(fingerprint); + Object.entries(tags).forEach(([key, value]) => { + scope.setTag(key, value); + }); + scope.setExtras(extra); + Sentry.captureException(error); + }); +} + +function maybeCaptureSupersededResponse( + context: ReactionMutationContext, + now: number +): void { + const latestMutationId = latestMutationIdByDrop.get(context.dropId); + if (!latestMutationId || latestMutationId === context.mutationId) { + return; + } + + context.supersededByMutationId = latestMutationId; + addReactionBreadcrumb( + "reaction.response_superseded", + context, + { + superseded_by_mutation_id: latestMutationId, + time_since_mutation_ms: now - context.startedAt, + }, + "warning" + ); + + const dedupeKey = [ + ANOMALY_OUT_OF_ORDER, + context.dropId, + context.source, + context.action, + latestMutationId, + ].join(":"); + if (!shouldCaptureEvent(dedupeKey, now)) { + return; + } + + captureReactionEvent({ + error: new Error("Reaction response superseded by newer mutation"), + level: "warning", + fingerprint: [REACTION_FEATURE, ANOMALY_OUT_OF_ORDER], + tags: { + feature: REACTION_FEATURE, + operation: REACTION_ANOMALY_OPERATION, + anomaly_kind: ANOMALY_OUT_OF_ORDER, + source: context.source, + action: context.action, + }, + extra: { + mutation_id: context.mutationId, + drop_mutation_seq: context.dropMutationSeq, + drop_id: context.dropId, + wave_id: context.waveId, + previous_reaction: context.previousReaction, + intended_reaction: context.intendedReaction, + optimistic_reaction: context.optimisticReaction, + profile_id: context.profileId ?? undefined, + pathname: context.pathname ?? undefined, + visibility_state: context.visibilityState ?? undefined, + online: context.online ?? undefined, + websocket_status: context.websocketStatus ?? undefined, + endpoint: context.endpoint ?? undefined, + method: context.method ?? undefined, + superseded_by_mutation_id: latestMutationId, + time_since_mutation_ms: now - context.startedAt, + anomaly_kind: ANOMALY_OUT_OF_ORDER, + }, + }); +} + +export function deriveReactionAction( + previousReaction: string | null | undefined, + intendedReaction: string | null | undefined +): ReactionAction { + const previous = toNullableReaction(previousReaction); + const intended = toNullableReaction(intendedReaction); + + if (intended === null) { + return "remove"; + } + + if (previous === null) { + return "add"; + } + + return "replace"; +} + +export function beginReactionMutation(params: { + dropId: string; + waveId: string; + source: ReactionSource; + action: ReactionAction; + previousReaction: string | null | undefined; + intendedReaction: string | null | undefined; + optimisticReaction: string | null | undefined; + profileId: string | null | undefined; + websocketStatus?: WebSocketStatus | string | null; +}): ReactionMutationContext { + const now = Date.now(); + pruneState(now); + + const nextSeq = (dropMutationSeqByDrop.get(params.dropId) ?? 0) + 1; + dropMutationSeqByDrop.set(params.dropId, nextSeq); + + const context: ReactionMutationContext = { + mutationId: createMutationId(), + dropMutationSeq: nextSeq, + dropId: params.dropId, + waveId: params.waveId, + source: params.source, + action: params.action, + previousReaction: toNullableReaction(params.previousReaction), + intendedReaction: toNullableReaction(params.intendedReaction), + optimisticReaction: toNullableReaction(params.optimisticReaction), + profileId: params.profileId ?? null, + startedAt: now, + pathname: getCurrentPathname(), + visibilityState: getVisibilityState(), + online: getOnlineStatus(), + websocketStatus: toWebsocketStatus(params.websocketStatus), + }; + + latestMutationIdByDrop.set(params.dropId, context.mutationId); + mutationContextById.set(context.mutationId, context); + + addReactionBreadcrumb("reaction.intent", context); + + return context; +} + +export function recordReactionOptimisticApplied( + context: ReactionMutationContext +): void { + addReactionBreadcrumb("reaction.optimistic_applied", context); +} + +export function recordReactionRequestSent( + context: ReactionMutationContext, + params: { + endpoint: string; + method: "POST" | "DELETE"; + } +): void { + context.endpoint = params.endpoint; + context.method = params.method; + context.requestSentAt = Date.now(); + + addReactionBreadcrumb("reaction.request_sent", context); +} + +export function recordReactionRequestSucceeded( + context: ReactionMutationContext +): void { + const now = Date.now(); + context.apiSucceededAt = now; + + maybeCaptureSupersededResponse(context, now); + + addReactionBreadcrumb("reaction.request_succeeded", context, { + latency_ms: now - (context.requestSentAt ?? context.startedAt), + }); +} + +export function recordReactionRequestFailed( + context: ReactionMutationContext, + error: unknown +): void { + const now = Date.now(); + context.apiFailedAt = now; + + maybeCaptureSupersededResponse(context, now); + + const { statusCode, errorKind } = classifyReactionError(error); + const latencyMs = now - (context.requestSentAt ?? context.startedAt); + const errorMessage = toErrorMessage(error); + + addReactionBreadcrumb( + "reaction.request_failed", + context, + { + status_code: statusCode ?? undefined, + latency_ms: latencyMs, + error_kind: errorKind, + error_message: errorMessage, + }, + "warning" + ); + + const dedupeKey = [ + "failure", + errorKind, + context.dropId, + context.source, + context.action, + statusCode ?? "na", + ].join(":"); + + if (!shouldCaptureEvent(dedupeKey, now)) { + context.failureCaptured = true; + return; + } + + captureReactionEvent({ + error: toCaptureExceptionInput(error), + level: errorKind === "server" ? "error" : "warning", + fingerprint: [REACTION_FEATURE, errorKind], + tags: { + feature: REACTION_FEATURE, + operation: REACTION_REQUEST_OPERATION, + source: context.source, + action: context.action, + error_kind: errorKind, + }, + extra: { + mutation_id: context.mutationId, + drop_mutation_seq: context.dropMutationSeq, + drop_id: context.dropId, + wave_id: context.waveId, + previous_reaction: context.previousReaction, + intended_reaction: context.intendedReaction, + optimistic_reaction: context.optimisticReaction, + profile_id: context.profileId ?? undefined, + pathname: context.pathname ?? undefined, + visibility_state: context.visibilityState ?? undefined, + online: context.online ?? undefined, + websocket_status: context.websocketStatus ?? undefined, + endpoint: context.endpoint ?? undefined, + method: context.method ?? undefined, + status_code: statusCode ?? undefined, + latency_ms: latencyMs, + error_kind: errorKind, + error_message: errorMessage, + }, + }); + + context.failureCaptured = true; +} + +export function recordReactionRollbackApplied( + context: ReactionMutationContext +): void { + addReactionBreadcrumb("reaction.rollback_applied", context); +} + +export function recordReactionRealtimeReconciliation(params: { + drop: Pick & { + readonly wave: Pick; + readonly context_profile_context: ApiDrop["context_profile_context"]; + }; + websocketStatus?: WebSocketStatus | string | null; +}): void { + const now = Date.now(); + pruneState(now); + + const latestMutationId = latestMutationIdByDrop.get(params.drop.id); + if (!latestMutationId) { + return; + } + + const context = mutationContextById.get(latestMutationId); + if (!context || now - context.startedAt > RECONCILIATION_WINDOW_MS) { + return; + } + + const serverReaction = params.drop.context_profile_context?.reaction ?? null; + + if (serverReaction === context.intendedReaction) { + addReactionBreadcrumb("reaction.realtime_reconciled", context, { + reconciled_from: "ws_refetch", + server_reaction: serverReaction ?? undefined, + time_since_mutation_ms: now - context.startedAt, + websocket_status: toWebsocketStatus(params.websocketStatus) ?? undefined, + }); + return; + } + + if (context.apiFailedAt !== null || context.apiSucceededAt === null) { + return; + } + + addReactionBreadcrumb( + "reaction.optimistic_reverted", + context, + { + reconciled_from: "ws_refetch", + server_reaction: serverReaction ?? undefined, + time_since_mutation_ms: now - context.startedAt, + websocket_status: toWebsocketStatus(params.websocketStatus) ?? undefined, + }, + "warning" + ); + + const dedupeKey = [ + ANOMALY_OPTIMISTIC_REVERTED, + context.dropId, + context.source, + context.action, + context.intendedReaction ?? "null", + serverReaction ?? "null", + ].join(":"); + + if (!shouldCaptureEvent(dedupeKey, now)) { + return; + } + + captureReactionEvent({ + error: new Error( + "Reaction optimistic state disagreed with canonical state" + ), + level: "warning", + fingerprint: [REACTION_FEATURE, ANOMALY_REVERTED_AFTER_SUCCESS], + tags: { + feature: REACTION_FEATURE, + operation: REACTION_ANOMALY_OPERATION, + anomaly_kind: ANOMALY_OPTIMISTIC_REVERTED, + source: context.source, + action: context.action, + }, + extra: { + mutation_id: context.mutationId, + drop_mutation_seq: context.dropMutationSeq, + drop_id: context.dropId, + wave_id: context.waveId, + previous_reaction: context.previousReaction, + intended_reaction: context.intendedReaction, + optimistic_reaction: context.optimisticReaction, + server_reaction: serverReaction ?? undefined, + profile_id: context.profileId ?? undefined, + pathname: context.pathname ?? undefined, + visibility_state: context.visibilityState ?? undefined, + online: context.online ?? undefined, + websocket_status: + toWebsocketStatus(params.websocketStatus) ?? + context.websocketStatus ?? + undefined, + endpoint: context.endpoint ?? undefined, + method: context.method ?? undefined, + reconciled_from: "ws_refetch", + time_since_mutation_ms: now - context.startedAt, + anomaly_kind: ANOMALY_OPTIMISTIC_REVERTED, + }, + }); +} + +export function __resetDropReactionMonitoringForTests(): void { + latestMutationIdByDrop.clear(); + dropMutationSeqByDrop.clear(); + mutationContextById.clear(); + dedupeEventAtByKey.clear(); +} From 004162545558cba40e481f5e606492f8ba636a6b Mon Sep 17 00:00:00 2001 From: Simo Date: Tue, 21 Apr 2026 09:55:49 +0300 Subject: [PATCH 02/15] wip Signed-off-by: Simo --- .../drops/WaveDropActionsAddReaction.test.tsx | 19 +++ .../waves/drops/WaveDropReactions.test.tsx | 45 ++++++- __tests__/hooks/useDropReaction.test.ts | 119 ++++++++++++++++++ .../monitoring/dropReactionMonitoring.test.ts | 3 +- components/waves/drops/WaveDropReactions.tsx | 7 +- components/waves/drops/reaction-utils.ts | 22 ++++ hooks/drops/useDropReaction.ts | 11 +- 7 files changed, 216 insertions(+), 10 deletions(-) create mode 100644 __tests__/hooks/useDropReaction.test.ts diff --git a/__tests__/components/waves/drops/WaveDropActionsAddReaction.test.tsx b/__tests__/components/waves/drops/WaveDropActionsAddReaction.test.tsx index 7843f167b6..c2035ba71a 100644 --- a/__tests__/components/waves/drops/WaveDropActionsAddReaction.test.tsx +++ b/__tests__/components/waves/drops/WaveDropActionsAddReaction.test.tsx @@ -4,6 +4,7 @@ import WaveDropActionsAddReaction from "@/components/waves/drops/WaveDropActions import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { DropSize } from "@/helpers/waves/drop.helpers"; import { ApiDropType } from "@/generated/models/ApiDropType"; +import * as commonApi from "@/services/api/common-api"; const applyOptimisticDropUpdateMock = jest.fn(() => ({ rollback: jest.fn() })); const setToastMock = jest.fn(); @@ -192,6 +193,24 @@ describe("WaveDropActionsAddReaction", () => { }); }); + it("shows the structured API error message when adding a reaction fails", async () => { + (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( + new Error("Unauthorized") + ); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /add reaction/i })); + fireEvent.click(await screen.findByText(/select emoji/i)); + + await waitFor(() => { + expect(setToastMock).toHaveBeenCalledWith({ + message: "Unauthorized", + type: "error", + }); + }); + }); + it("opens and closes picker on mobile button click", async () => { render(); const button = screen.getByRole("button", { name: /add reaction/i }); diff --git a/__tests__/components/waves/drops/WaveDropReactions.test.tsx b/__tests__/components/waves/drops/WaveDropReactions.test.tsx index cddc7b305c..80d78ba4df 100644 --- a/__tests__/components/waves/drops/WaveDropReactions.test.tsx +++ b/__tests__/components/waves/drops/WaveDropReactions.test.tsx @@ -67,6 +67,7 @@ jest.mock("@/hooks/useLongPressInteraction", () => ({ const mockUseEmoji = useEmoji as jest.Mock; const mockUseAuth = useAuth as jest.Mock; +const setToastMock = jest.fn(); type NativeEmojiMock = { skins: Array<{ native: string }> }; @@ -114,7 +115,7 @@ describe("WaveDropReactions", () => { jest.clearAllMocks(); mockUseAuth.mockReturnValue({ connectedProfile: { id: "profile-1", handle: "alice" }, - setToast: jest.fn(), + setToast: setToastMock, }); getMyStreamMock().mockReturnValue({ applyOptimisticDropUpdate: jest.fn(() => ({ rollback: jest.fn() })), @@ -291,6 +292,48 @@ describe("WaveDropReactions", () => { }); }); + it("shows the structured API error message when a chip reaction fails", async () => { + mockUseEmoji.mockReturnValue( + createEmojiContextValue( + [ + { + category: "people", + emojis: [{ id: "gm", skins: [{ src: "/gm.png" }] }], + }, + ], + () => null + ) + ); + + (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( + new Error("Unauthorized") + ); + + render( + + ); + + fireEvent.click(screen.getAllByRole("button")[0]); + + await waitFor(() => { + expect(setToastMock).toHaveBeenCalledWith({ + message: "Unauthorized", + type: "error", + }); + }); + }); + it("renders reaction pills as non-interactive when disconnected", () => { mockUseAuth.mockReturnValue({ connectedProfile: null, diff --git a/__tests__/hooks/useDropReaction.test.ts b/__tests__/hooks/useDropReaction.test.ts new file mode 100644 index 0000000000..3994da03e9 --- /dev/null +++ b/__tests__/hooks/useDropReaction.test.ts @@ -0,0 +1,119 @@ +import { useDropReaction } from "@/hooks/drops/useDropReaction"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { DropSize } from "@/helpers/waves/drop.helpers"; +import { ApiDropType } from "@/generated/models/ApiDropType"; +import { useAuth } from "@/components/auth/Auth"; +import { useMyStream } from "@/contexts/wave/MyStreamContext"; +import * as commonApi from "@/services/api/common-api"; +import { act, renderHook } from "@testing-library/react"; + +const setToastMock = jest.fn(); +const applyOptimisticDropUpdateMock = jest.fn(() => ({ rollback: jest.fn() })); + +jest.mock("@/components/auth/Auth", () => ({ + useAuth: jest.fn(), +})); + +jest.mock("@/contexts/wave/MyStreamContext", () => ({ + useMyStream: jest.fn(), +})); + +jest.mock("@/services/api/common-api", () => ({ + commonApiPost: jest.fn(), + commonApiDelete: jest.fn(), +})); + +jest.mock("@/helpers/reactions/reactionHistory", () => ({ + recordReaction: jest.fn(), +})); + +jest.mock("@/services/websocket/useWebSocketMessage", () => ({ + useWebsocketStatus: jest.fn(() => "connected"), +})); + +jest.mock("@/utils/monitoring/dropReactionMonitoring", () => ({ + beginReactionMutation: jest.fn(() => ({ mutationId: "mutation-1" })), + deriveReactionAction: jest.fn(() => "add"), + recordReactionOptimisticApplied: jest.fn(), + recordReactionRequestFailed: jest.fn(), + recordReactionRequestSent: jest.fn(), + recordReactionRequestSucceeded: jest.fn(), + recordReactionRollbackApplied: jest.fn(), +})); + +const mockUseAuth = useAuth as jest.Mock; +const mockUseMyStream = useMyStream as jest.Mock; + +const mockDrop = { + id: "drop-1", + wave: { id: "wave-1" }, + context_profile_context: { reaction: null }, + author: { handle: "author-handle" }, + parts: [], + metadata: [], + drop_type: ApiDropType.Standard, + serial_no: 1, + created_at: new Date().toISOString(), + reply_to: null, + wave_messages: [], + reactions: [], + type: DropSize.FULL, + stableKey: "drop-1", + stableHash: "hash-drop-1", +} as unknown as ExtendedDrop; + +describe("useDropReaction", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseAuth.mockReturnValue({ + setToast: setToastMock, + connectedProfile: { + id: "identity-1", + handle: "user", + pfp: null, + banner1: null, + banner2: null, + cic: 0, + rep: 0, + tdh: 0, + tdh_rate: 0, + xtdh: 0, + xtdh_rate: 0, + level: 0, + primary_wallet: "0xuser", + active_main_stage_submission_ids: [], + winner_main_stage_drop_ids: [], + is_wave_creator: false, + artist_of_prevote_cards: [], + profile_wave_id: null, + }, + }); + mockUseMyStream.mockReturnValue({ + applyOptimisticDropUpdate: applyOptimisticDropUpdateMock, + }); + }); + + it("shows structured API error messages for quick react failures", async () => { + (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( + new Error("Rate limited") + ); + + const { result } = renderHook(() => + useDropReaction(mockDrop, { source: "quick-react" }) + ); + + await act(async () => { + await result.current.react(":smile:"); + }); + + expect(commonApi.commonApiPost).toHaveBeenCalledWith({ + endpoint: "drops/drop-1/reaction", + body: { reaction: ":smile:" }, + errorMode: "structured", + }); + expect(setToastMock).toHaveBeenCalledWith({ + message: "Rate limited", + type: "error", + }); + }); +}); diff --git a/__tests__/utils/monitoring/dropReactionMonitoring.test.ts b/__tests__/utils/monitoring/dropReactionMonitoring.test.ts index 26e93ddd51..cc8bf59ca5 100644 --- a/__tests__/utils/monitoring/dropReactionMonitoring.test.ts +++ b/__tests__/utils/monitoring/dropReactionMonitoring.test.ts @@ -192,7 +192,7 @@ describe("dropReactionMonitoring", () => { ); }); - it("captures reconciliation mismatch only after apparent success", () => { + it("captures reconciliation mismatch after success when failure timestamp is undefined", () => { const mutation = beginReactionMutation({ dropId: "drop-4", waveId: "wave-1", @@ -212,6 +212,7 @@ describe("dropReactionMonitoring", () => { dateNowSpy.mockReturnValue(1_100); recordReactionRequestSucceeded(mutation); + expect((mutation as any).apiFailedAt).toBeUndefined(); dateNowSpy.mockReturnValue(1_500); recordReactionRealtimeReconciliation({ diff --git a/components/waves/drops/WaveDropReactions.tsx b/components/waves/drops/WaveDropReactions.tsx index e591454705..aeadf28588 100644 --- a/components/waves/drops/WaveDropReactions.tsx +++ b/components/waves/drops/WaveDropReactions.tsx @@ -29,6 +29,7 @@ import { Tooltip } from "react-tooltip"; import { cloneReactionEntries, findReactionIndex, + getReactionErrorMessage, removeUserFromReactions, toProfileMin, } from "./reaction-utils"; @@ -389,8 +390,10 @@ function WaveDropReaction({ recordReactionRequestSucceeded(mutation); } catch (error) { recordReactionRequestFailed(mutation, error); - let msg = selected ? "Error removing reaction" : "Error adding reaction"; - if (typeof error === "string") msg = error; + const msg = getReactionErrorMessage( + error, + selected ? "Error removing reaction" : "Error adding reaction" + ); setToast({ message: msg, type: "error" }); setSelected((s) => !s); diff --git a/components/waves/drops/reaction-utils.ts b/components/waves/drops/reaction-utils.ts index bfb9028fcf..e441721992 100644 --- a/components/waves/drops/reaction-utils.ts +++ b/components/waves/drops/reaction-utils.ts @@ -107,3 +107,25 @@ export const toProfileMin = ( profile_wave_id: profile.profile_wave_id, }; }; + +export const getReactionErrorMessage = ( + error: unknown, + fallback: string +): string => { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + + if (typeof error === "object" && error !== null) { + const message = (error as { message?: unknown }).message; + if (typeof message === "string" && message.trim().length > 0) { + return message; + } + } + + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + + return fallback; +}; diff --git a/hooks/drops/useDropReaction.ts b/hooks/drops/useDropReaction.ts index 0f62b7cac5..ba02ed7777 100644 --- a/hooks/drops/useDropReaction.ts +++ b/hooks/drops/useDropReaction.ts @@ -14,6 +14,7 @@ import { useCallback, useRef } from "react"; import { cloneReactionEntries, findReactionIndex, + getReactionErrorMessage, removeUserFromReactions, toProfileMin, } from "@/components/waves/drops/reaction-utils"; @@ -190,12 +191,10 @@ export function useDropReaction( onSuccess?.(); } catch (error) { recordReactionRequestFailed(mutation, error); - let errorMessage = isRemoving - ? "Error removing reaction" - : "Error adding reaction"; - if (typeof error === "string") { - errorMessage = error; - } + const errorMessage = getReactionErrorMessage( + error, + isRemoving ? "Error removing reaction" : "Error adding reaction" + ); setToast({ message: errorMessage, type: "error" }); rollbackRef.current?.(); recordReactionRollbackApplied(mutation); From 1655569080722ad9f69934c523d3fc592f095aa4 Mon Sep 17 00:00:00 2001 From: Simo Date: Tue, 21 Apr 2026 11:14:35 +0300 Subject: [PATCH 03/15] wip Signed-off-by: Simo --- .../waves/drops/WaveDropReactions.test.tsx | 14 +++- .../waves/drops/reaction-utils.test.ts | 72 ++++++++++++++++++ __tests__/hooks/useDropReaction.test.ts | 34 ++++++++- components/waves/drops/reaction-utils.ts | 74 ++++++++++++++++--- 4 files changed, 180 insertions(+), 14 deletions(-) create mode 100644 __tests__/components/waves/drops/reaction-utils.test.ts diff --git a/__tests__/components/waves/drops/WaveDropReactions.test.tsx b/__tests__/components/waves/drops/WaveDropReactions.test.tsx index 80d78ba4df..b65157e3a9 100644 --- a/__tests__/components/waves/drops/WaveDropReactions.test.tsx +++ b/__tests__/components/waves/drops/WaveDropReactions.test.tsx @@ -68,6 +68,13 @@ jest.mock("@/hooks/useLongPressInteraction", () => ({ const mockUseEmoji = useEmoji as jest.Mock; const mockUseAuth = useAuth as jest.Mock; const setToastMock = jest.fn(); +const createStructuredReactionError = ( + body: unknown, + message = "technical error" +): Error & { response: { body: unknown } } => + Object.assign(new Error(message), { + response: { body }, + }); type NativeEmojiMock = { skins: Array<{ native: string }> }; @@ -280,6 +287,7 @@ describe("WaveDropReactions", () => { expect(commonApi.commonApiPost).toHaveBeenCalledWith({ endpoint: "drops/test-drop/reaction", body: { reaction: ":gm:" }, + errorMode: "structured", }); // Click button again to decrement @@ -289,6 +297,7 @@ describe("WaveDropReactions", () => { }); expect(commonApi.commonApiDelete).toHaveBeenCalledWith({ endpoint: "drops/test-drop/reaction", + errorMode: "structured", }); }); @@ -306,7 +315,10 @@ describe("WaveDropReactions", () => { ); (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( - new Error("Unauthorized") + createStructuredReactionError( + JSON.stringify({ message: "Unauthorized" }), + "unexpected raw error" + ) ); render( diff --git a/__tests__/components/waves/drops/reaction-utils.test.ts b/__tests__/components/waves/drops/reaction-utils.test.ts new file mode 100644 index 0000000000..57fd4003f8 --- /dev/null +++ b/__tests__/components/waves/drops/reaction-utils.test.ts @@ -0,0 +1,72 @@ +import { getReactionErrorMessage } from "@/components/waves/drops/reaction-utils"; + +const createStructuredReactionError = ( + body: unknown, + message = "technical error" +): Error & { response: { body: unknown } } => + Object.assign(new Error(message), { + response: { body }, + }); + +describe("getReactionErrorMessage", () => { + it("surfaces the error field from structured API errors", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError( + JSON.stringify({ error: "Rate limited" }), + "unexpected raw error" + ), + "Error adding reaction" + ) + ).toBe("Rate limited"); + }); + + it("surfaces the message field from structured API errors", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError( + JSON.stringify({ message: "Unauthorized" }), + "unexpected raw error" + ), + "Error adding reaction" + ) + ).toBe("Unauthorized"); + }); + + it("surfaces the first details message from structured API errors", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError( + JSON.stringify({ + details: [{ message: "Reaction not allowed" }], + }), + "unexpected raw error" + ), + "Error adding reaction" + ) + ).toBe("Reaction not allowed"); + }); + + it("falls back for non-JSON structured error bodies", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError( + "Bad Gateway", + "Bad Gateway" + ), + "Error adding reaction" + ) + ).toBe("Error adding reaction"); + }); + + it("falls back for generic network errors", () => { + expect( + getReactionErrorMessage( + new Error( + "Network request failed. Please check your connection and try again. (https://api.test.6529.io/api/drops/drop-1/reaction)" + ), + "Error adding reaction" + ) + ).toBe("Error adding reaction"); + }); +}); diff --git a/__tests__/hooks/useDropReaction.test.ts b/__tests__/hooks/useDropReaction.test.ts index 3994da03e9..564844948f 100644 --- a/__tests__/hooks/useDropReaction.test.ts +++ b/__tests__/hooks/useDropReaction.test.ts @@ -43,6 +43,13 @@ jest.mock("@/utils/monitoring/dropReactionMonitoring", () => ({ const mockUseAuth = useAuth as jest.Mock; const mockUseMyStream = useMyStream as jest.Mock; +const createStructuredReactionError = ( + body: unknown, + message = "technical error" +): Error & { response: { body: unknown } } => + Object.assign(new Error(message), { + response: { body }, + }); const mockDrop = { id: "drop-1", @@ -95,7 +102,10 @@ describe("useDropReaction", () => { it("shows structured API error messages for quick react failures", async () => { (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( - new Error("Rate limited") + createStructuredReactionError( + JSON.stringify({ error: "Rate limited" }), + "unexpected raw error" + ) ); const { result } = renderHook(() => @@ -116,4 +126,26 @@ describe("useDropReaction", () => { type: "error", }); }); + + it("falls back for unsafe structured quick react failures", async () => { + (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( + createStructuredReactionError( + "Bad Gateway", + "Bad Gateway" + ) + ); + + const { result } = renderHook(() => + useDropReaction(mockDrop, { source: "quick-react" }) + ); + + await act(async () => { + await result.current.react(":smile:"); + }); + + expect(setToastMock).toHaveBeenCalledWith({ + message: "Error adding reaction", + type: "error", + }); + }); }); diff --git a/components/waves/drops/reaction-utils.ts b/components/waves/drops/reaction-utils.ts index e441721992..d873ebfe8a 100644 --- a/components/waves/drops/reaction-utils.ts +++ b/components/waves/drops/reaction-utils.ts @@ -108,23 +108,73 @@ export const toProfileMin = ( }; }; -export const getReactionErrorMessage = ( - error: unknown, - fallback: string -): string => { - if (error instanceof Error && error.message.trim().length > 0) { - return error.message; +type StructuredReactionError = { + response?: { + body?: unknown; + }; +}; + +const isNonEmptyUnknownArray = (value: unknown): value is readonly unknown[] => + Array.isArray(value) && value.length > 0; + +const getDetailsFirstMessage = (details: unknown): string | null => { + if (!isNonEmptyUnknownArray(details)) { + return null; + } + + const [firstDetail] = details; + if (firstDetail === null || typeof firstDetail !== "object") { + return null; } - if (typeof error === "object" && error !== null) { - const message = (error as { message?: unknown }).message; - if (typeof message === "string" && message.trim().length > 0) { - return message; + const message = (firstDetail as { message?: unknown }).message; + if (typeof message === "string" && message.trim().length > 0) { + return message; + } + + return null; +}; + +// Reaction toasts should only surface messages from known API JSON fields. +const getStructuredReactionBodyMessage = (body: unknown): string | null => { + if (typeof body === "string") { + try { + return getStructuredReactionBodyMessage(JSON.parse(body) as unknown); + } catch { + return null; } } - if (typeof error === "string" && error.trim().length > 0) { - return error; + if (body === null || typeof body !== "object") { + return null; + } + + const bodyRecord = body as Record; + const errorMessage = bodyRecord["error"]; + if (typeof errorMessage === "string" && errorMessage.trim().length > 0) { + return errorMessage; + } + + const message = bodyRecord["message"]; + if (typeof message === "string" && message.trim().length > 0) { + return message; + } + + return getDetailsFirstMessage(bodyRecord["details"]); +}; + +export const getReactionErrorMessage = ( + error: unknown, + fallback: string +): string => { + if (error !== null && typeof error === "object") { + const structuredError = error as StructuredReactionError; + const safeMessage = getStructuredReactionBodyMessage( + structuredError.response?.body + ); + if (safeMessage) { + return safeMessage; + } } return fallback; From 11a3ddfad179364f7354de9ac45a9117ce2661c1 Mon Sep 17 00:00:00 2001 From: Simo Date: Tue, 21 Apr 2026 11:26:25 +0300 Subject: [PATCH 04/15] wip Signed-off-by: Simo --- .../waves/drops/reaction-utils.test.ts | 30 ++++++++++++++++++ __tests__/hooks/useDropReaction.test.ts | 19 ++++++++++++ components/waves/drops/reaction-utils.ts | 31 ++++++++++++++++--- 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/__tests__/components/waves/drops/reaction-utils.test.ts b/__tests__/components/waves/drops/reaction-utils.test.ts index 57fd4003f8..0ebadfc0e5 100644 --- a/__tests__/components/waves/drops/reaction-utils.test.ts +++ b/__tests__/components/waves/drops/reaction-utils.test.ts @@ -59,6 +59,24 @@ describe("getReactionErrorMessage", () => { ).toBe("Error adding reaction"); }); + it("falls back to the structured error message when the body is missing", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError(undefined, "Unauthorized"), + "Error adding reaction" + ) + ).toBe("Unauthorized"); + }); + + it("falls back to the structured error message when the body is blank", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError(" ", "Too Many Requests"), + "Error adding reaction" + ) + ).toBe("Too Many Requests"); + }); + it("falls back for generic network errors", () => { expect( getReactionErrorMessage( @@ -69,4 +87,16 @@ describe("getReactionErrorMessage", () => { ) ).toBe("Error adding reaction"); }); + + it("does not use the raw error message when an unsafe body is present", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError( + "Bad Gateway", + "Unauthorized" + ), + "Error adding reaction" + ) + ).toBe("Error adding reaction"); + }); }); diff --git a/__tests__/hooks/useDropReaction.test.ts b/__tests__/hooks/useDropReaction.test.ts index 564844948f..b2f4d7d79e 100644 --- a/__tests__/hooks/useDropReaction.test.ts +++ b/__tests__/hooks/useDropReaction.test.ts @@ -148,4 +148,23 @@ describe("useDropReaction", () => { type: "error", }); }); + + it("shows the structured error message when the structured body is empty", async () => { + (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( + createStructuredReactionError(undefined, "Unauthorized") + ); + + const { result } = renderHook(() => + useDropReaction(mockDrop, { source: "quick-react" }) + ); + + await act(async () => { + await result.current.react(":smile:"); + }); + + expect(setToastMock).toHaveBeenCalledWith({ + message: "Unauthorized", + type: "error", + }); + }); }); diff --git a/components/waves/drops/reaction-utils.ts b/components/waves/drops/reaction-utils.ts index d873ebfe8a..acc8a36e2e 100644 --- a/components/waves/drops/reaction-utils.ts +++ b/components/waves/drops/reaction-utils.ts @@ -109,6 +109,7 @@ export const toProfileMin = ( }; type StructuredReactionError = { + message?: unknown; response?: { body?: unknown; }; @@ -163,17 +164,37 @@ const getStructuredReactionBodyMessage = (body: unknown): string | null => { return getDetailsFirstMessage(bodyRecord["details"]); }; +const hasNoStructuredReactionBody = (body: unknown): boolean => { + if (body === null || body === undefined) { + return true; + } + + return typeof body === "string" && body.trim().length === 0; +}; + export const getReactionErrorMessage = ( error: unknown, fallback: string ): string => { if (error !== null && typeof error === "object") { const structuredError = error as StructuredReactionError; - const safeMessage = getStructuredReactionBodyMessage( - structuredError.response?.body - ); - if (safeMessage) { - return safeMessage; + const structuredResponse = structuredError.response; + if (structuredResponse !== undefined) { + const structuredBody = structuredResponse.body; + const safeMessage = getStructuredReactionBodyMessage(structuredBody); + if (safeMessage) { + return safeMessage; + } + + if (hasNoStructuredReactionBody(structuredBody)) { + const errorMessage = structuredError.message; + if ( + typeof errorMessage === "string" && + errorMessage.trim().length > 0 + ) { + return errorMessage; + } + } } } From dc1ff178ff3a227ff7c61eb61dcf3ca2808e4d45 Mon Sep 17 00:00:00 2001 From: Simo Date: Tue, 21 Apr 2026 11:46:00 +0300 Subject: [PATCH 05/15] wip Signed-off-by: Simo --- .../waves/drops/reaction-utils.test.ts | 93 +++++++++++++------ __tests__/hooks/useDropReaction.test.ts | 70 +++++++++++--- components/waves/drops/reaction-utils.ts | 45 +++++++-- 3 files changed, 157 insertions(+), 51 deletions(-) diff --git a/__tests__/components/waves/drops/reaction-utils.test.ts b/__tests__/components/waves/drops/reaction-utils.test.ts index 0ebadfc0e5..c063fbef5d 100644 --- a/__tests__/components/waves/drops/reaction-utils.test.ts +++ b/__tests__/components/waves/drops/reaction-utils.test.ts @@ -1,21 +1,34 @@ import { getReactionErrorMessage } from "@/components/waves/drops/reaction-utils"; -const createStructuredReactionError = ( - body: unknown, - message = "technical error" -): Error & { response: { body: unknown } } => +const createStructuredReactionError = ({ + body, + message = "technical error", + status, +}: { + body?: unknown; + message?: string; + status?: number; +}): Error & { + status?: number; + response: { body?: unknown; status?: number }; +} => Object.assign(new Error(message), { - response: { body }, + ...(status !== undefined ? { status } : {}), + response: { + ...(body !== undefined ? { body } : {}), + ...(status !== undefined ? { status } : {}), + }, }); describe("getReactionErrorMessage", () => { it("surfaces the error field from structured API errors", () => { expect( getReactionErrorMessage( - createStructuredReactionError( - JSON.stringify({ error: "Rate limited" }), - "unexpected raw error" - ), + createStructuredReactionError({ + body: JSON.stringify({ error: "Rate limited" }), + message: "unexpected raw error", + status: 429, + }), "Error adding reaction" ) ).toBe("Rate limited"); @@ -24,10 +37,11 @@ describe("getReactionErrorMessage", () => { it("surfaces the message field from structured API errors", () => { expect( getReactionErrorMessage( - createStructuredReactionError( - JSON.stringify({ message: "Unauthorized" }), - "unexpected raw error" - ), + createStructuredReactionError({ + body: JSON.stringify({ message: "Unauthorized" }), + message: "unexpected raw error", + status: 401, + }), "Error adding reaction" ) ).toBe("Unauthorized"); @@ -36,12 +50,12 @@ describe("getReactionErrorMessage", () => { it("surfaces the first details message from structured API errors", () => { expect( getReactionErrorMessage( - createStructuredReactionError( - JSON.stringify({ + createStructuredReactionError({ + body: JSON.stringify({ details: [{ message: "Reaction not allowed" }], }), - "unexpected raw error" - ), + message: "unexpected raw error", + }), "Error adding reaction" ) ).toBe("Reaction not allowed"); @@ -50,33 +64,53 @@ describe("getReactionErrorMessage", () => { it("falls back for non-JSON structured error bodies", () => { expect( getReactionErrorMessage( - createStructuredReactionError( - "Bad Gateway", - "Bad Gateway" - ), + createStructuredReactionError({ + body: "Bad Gateway", + message: "Bad Gateway", + status: 502, + }), "Error adding reaction" ) ).toBe("Error adding reaction"); }); - it("falls back to the structured error message when the body is missing", () => { + it("maps unauthorized status when the structured body is missing", () => { expect( getReactionErrorMessage( - createStructuredReactionError(undefined, "Unauthorized"), + createStructuredReactionError({ + message: "Something went wrong", + status: 401, + }), "Error adding reaction" ) ).toBe("Unauthorized"); }); - it("falls back to the structured error message when the body is blank", () => { + it("maps rate-limit status when the structured body is blank", () => { expect( getReactionErrorMessage( - createStructuredReactionError(" ", "Too Many Requests"), + createStructuredReactionError({ + body: " ", + message: " ", + status: 429, + }), "Error adding reaction" ) ).toBe("Too Many Requests"); }); + it("falls back when the structured body is missing and status is unsupported", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + message: "Unauthorized", + status: 503, + }), + "Error adding reaction" + ) + ).toBe("Error adding reaction"); + }); + it("falls back for generic network errors", () => { expect( getReactionErrorMessage( @@ -91,10 +125,11 @@ describe("getReactionErrorMessage", () => { it("does not use the raw error message when an unsafe body is present", () => { expect( getReactionErrorMessage( - createStructuredReactionError( - "Bad Gateway", - "Unauthorized" - ), + createStructuredReactionError({ + body: "Bad Gateway", + message: "Unauthorized", + status: 401, + }), "Error adding reaction" ) ).toBe("Error adding reaction"); diff --git a/__tests__/hooks/useDropReaction.test.ts b/__tests__/hooks/useDropReaction.test.ts index b2f4d7d79e..0c0cbbaa94 100644 --- a/__tests__/hooks/useDropReaction.test.ts +++ b/__tests__/hooks/useDropReaction.test.ts @@ -43,12 +43,24 @@ jest.mock("@/utils/monitoring/dropReactionMonitoring", () => ({ const mockUseAuth = useAuth as jest.Mock; const mockUseMyStream = useMyStream as jest.Mock; -const createStructuredReactionError = ( - body: unknown, - message = "technical error" -): Error & { response: { body: unknown } } => +const createStructuredReactionError = ({ + body, + message = "technical error", + status, +}: { + body?: unknown; + message?: string; + status?: number; +}): Error & { + status?: number; + response: { body?: unknown; status?: number }; +} => Object.assign(new Error(message), { - response: { body }, + ...(status !== undefined ? { status } : {}), + response: { + ...(body !== undefined ? { body } : {}), + ...(status !== undefined ? { status } : {}), + }, }); const mockDrop = { @@ -102,10 +114,11 @@ describe("useDropReaction", () => { it("shows structured API error messages for quick react failures", async () => { (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( - createStructuredReactionError( - JSON.stringify({ error: "Rate limited" }), - "unexpected raw error" - ) + createStructuredReactionError({ + body: JSON.stringify({ error: "Rate limited" }), + message: "unexpected raw error", + status: 429, + }) ); const { result } = renderHook(() => @@ -129,10 +142,11 @@ describe("useDropReaction", () => { it("falls back for unsafe structured quick react failures", async () => { (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( - createStructuredReactionError( - "Bad Gateway", - "Bad Gateway" - ) + createStructuredReactionError({ + body: "Bad Gateway", + message: "Bad Gateway", + status: 502, + }) ); const { result } = renderHook(() => @@ -149,9 +163,12 @@ describe("useDropReaction", () => { }); }); - it("shows the structured error message when the structured body is empty", async () => { + it("maps unauthorized status when the structured body is empty", async () => { (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( - createStructuredReactionError(undefined, "Unauthorized") + createStructuredReactionError({ + message: "Something went wrong", + status: 401, + }) ); const { result } = renderHook(() => @@ -167,4 +184,27 @@ describe("useDropReaction", () => { type: "error", }); }); + + it("maps rate-limit status when the structured body is blank", async () => { + (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( + createStructuredReactionError({ + body: " ", + message: " ", + status: 429, + }) + ); + + const { result } = renderHook(() => + useDropReaction(mockDrop, { source: "quick-react" }) + ); + + await act(async () => { + await result.current.react(":smile:"); + }); + + expect(setToastMock).toHaveBeenCalledWith({ + message: "Too Many Requests", + type: "error", + }); + }); }); diff --git a/components/waves/drops/reaction-utils.ts b/components/waves/drops/reaction-utils.ts index acc8a36e2e..c1b4b5952f 100644 --- a/components/waves/drops/reaction-utils.ts +++ b/components/waves/drops/reaction-utils.ts @@ -109,8 +109,9 @@ export const toProfileMin = ( }; type StructuredReactionError = { - message?: unknown; + status?: unknown; response?: { + status?: unknown; body?: unknown; }; }; @@ -172,6 +173,37 @@ const hasNoStructuredReactionBody = (body: unknown): boolean => { return typeof body === "string" && body.trim().length === 0; }; +const getStructuredReactionStatus = ( + error: StructuredReactionError +): number | null => { + const directStatus = error.status; + if (typeof directStatus === "number") { + return directStatus; + } + + const responseStatus = error.response?.status; + if (typeof responseStatus === "number") { + return responseStatus; + } + + return null; +}; + +const getEmptyStructuredReactionStatusMessage = ( + status: number | null +): string | null => { + switch (status) { + case 401: + return "Unauthorized"; + case 429: + return "Too Many Requests"; + case null: + return null; + default: + return null; + } +}; + export const getReactionErrorMessage = ( error: unknown, fallback: string @@ -187,12 +219,11 @@ export const getReactionErrorMessage = ( } if (hasNoStructuredReactionBody(structuredBody)) { - const errorMessage = structuredError.message; - if ( - typeof errorMessage === "string" && - errorMessage.trim().length > 0 - ) { - return errorMessage; + const statusMessage = getEmptyStructuredReactionStatusMessage( + getStructuredReactionStatus(structuredError) + ); + if (statusMessage) { + return statusMessage; } } } From c2b2b72024fd32aea9e33df6af4426349372c6cb Mon Sep 17 00:00:00 2001 From: Simo Date: Tue, 21 Apr 2026 12:06:10 +0300 Subject: [PATCH 06/15] wip Signed-off-by: Simo --- .../waves/drops/WaveDropReactions.test.tsx | 76 ++++++++++++++++--- .../waves/drops/reaction-utils.test.ts | 19 ++++- __tests__/hooks/useDropReaction.test.ts | 22 ++++++ components/waves/drops/reaction-utils.ts | 25 +++++- 4 files changed, 129 insertions(+), 13 deletions(-) diff --git a/__tests__/components/waves/drops/WaveDropReactions.test.tsx b/__tests__/components/waves/drops/WaveDropReactions.test.tsx index b65157e3a9..06144c2fca 100644 --- a/__tests__/components/waves/drops/WaveDropReactions.test.tsx +++ b/__tests__/components/waves/drops/WaveDropReactions.test.tsx @@ -68,12 +68,24 @@ jest.mock("@/hooks/useLongPressInteraction", () => ({ const mockUseEmoji = useEmoji as jest.Mock; const mockUseAuth = useAuth as jest.Mock; const setToastMock = jest.fn(); -const createStructuredReactionError = ( - body: unknown, - message = "technical error" -): Error & { response: { body: unknown } } => +const createStructuredReactionError = ({ + body, + message = "technical error", + status, +}: { + body?: unknown; + message?: string; + status?: number; +}): Error & { + status?: number; + response: { body?: unknown; status?: number }; +} => Object.assign(new Error(message), { - response: { body }, + ...(status !== undefined ? { status } : {}), + response: { + ...(body !== undefined ? { body } : {}), + ...(status !== undefined ? { status } : {}), + }, }); type NativeEmojiMock = { skins: Array<{ native: string }> }; @@ -315,10 +327,10 @@ describe("WaveDropReactions", () => { ); (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( - createStructuredReactionError( - JSON.stringify({ message: "Unauthorized" }), - "unexpected raw error" - ) + createStructuredReactionError({ + body: JSON.stringify({ message: "Unauthorized" }), + message: "unexpected raw error", + }) ); render( @@ -346,6 +358,52 @@ describe("WaveDropReactions", () => { }); }); + it("shows the safe status-text message when a chip reaction gets an empty structured response", async () => { + mockUseEmoji.mockReturnValue( + createEmojiContextValue( + [ + { + category: "people", + emojis: [{ id: "gm", skins: [{ src: "/gm.png" }] }], + }, + ], + () => null + ) + ); + + (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( + createStructuredReactionError({ + body: " ", + message: "Not Found", + status: 404, + }) + ); + + render( + + ); + + fireEvent.click(screen.getAllByRole("button")[0]); + + await waitFor(() => { + expect(setToastMock).toHaveBeenCalledWith({ + message: "Not Found", + type: "error", + }); + }); + }); + it("renders reaction pills as non-interactive when disconnected", () => { mockUseAuth.mockReturnValue({ connectedProfile: null, diff --git a/__tests__/components/waves/drops/reaction-utils.test.ts b/__tests__/components/waves/drops/reaction-utils.test.ts index c063fbef5d..29bbcd91c6 100644 --- a/__tests__/components/waves/drops/reaction-utils.test.ts +++ b/__tests__/components/waves/drops/reaction-utils.test.ts @@ -99,16 +99,29 @@ describe("getReactionErrorMessage", () => { ).toBe("Too Many Requests"); }); - it("falls back when the structured body is missing and status is unsupported", () => { + it("surfaces the structured error message when the structured body is missing", () => { expect( getReactionErrorMessage( createStructuredReactionError({ - message: "Unauthorized", + message: "Service Unavailable", status: 503, }), "Error adding reaction" ) - ).toBe("Error adding reaction"); + ).toBe("Service Unavailable"); + }); + + it("surfaces the structured error message when the structured body is blank", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + body: " ", + message: "Not Found", + status: 404, + }), + "Error adding reaction" + ) + ).toBe("Not Found"); }); it("falls back for generic network errors", () => { diff --git a/__tests__/hooks/useDropReaction.test.ts b/__tests__/hooks/useDropReaction.test.ts index 0c0cbbaa94..6fab7f735c 100644 --- a/__tests__/hooks/useDropReaction.test.ts +++ b/__tests__/hooks/useDropReaction.test.ts @@ -207,4 +207,26 @@ describe("useDropReaction", () => { type: "error", }); }); + + it("surfaces the safe status-text message when the structured body is missing", async () => { + (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( + createStructuredReactionError({ + message: "Service Unavailable", + status: 503, + }) + ); + + const { result } = renderHook(() => + useDropReaction(mockDrop, { source: "quick-react" }) + ); + + await act(async () => { + await result.current.react(":smile:"); + }); + + expect(setToastMock).toHaveBeenCalledWith({ + message: "Service Unavailable", + type: "error", + }); + }); }); diff --git a/components/waves/drops/reaction-utils.ts b/components/waves/drops/reaction-utils.ts index c1b4b5952f..c559b92abd 100644 --- a/components/waves/drops/reaction-utils.ts +++ b/components/waves/drops/reaction-utils.ts @@ -108,7 +108,7 @@ export const toProfileMin = ( }; }; -type StructuredReactionError = { +type StructuredReactionError = Error & { status?: unknown; response?: { status?: unknown; @@ -204,6 +204,23 @@ const getEmptyStructuredReactionStatusMessage = ( } }; +const getEmptyStructuredReactionFallbackMessage = ( + error: StructuredReactionError +): string | null => { + const message = error.message; + if (typeof message !== "string") { + return null; + } + + const trimmedMessage = message.trim(); + + if (trimmedMessage.length === 0) { + return null; + } + + return trimmedMessage; +}; + export const getReactionErrorMessage = ( error: unknown, fallback: string @@ -225,6 +242,12 @@ export const getReactionErrorMessage = ( if (statusMessage) { return statusMessage; } + + const fallbackMessage = + getEmptyStructuredReactionFallbackMessage(structuredError); + if (fallbackMessage) { + return fallbackMessage; + } } } } From 42bc3c4e72ce06f94105d973afac65963b1546a5 Mon Sep 17 00:00:00 2001 From: Simo Date: Tue, 21 Apr 2026 12:27:25 +0300 Subject: [PATCH 07/15] wip Signed-off-by: Simo --- .../waves/drops/WaveDropReactions.test.tsx | 8 +++- .../waves/drops/reaction-utils.test.ts | 27 +++++++++-- __tests__/services/common-api.test.ts | 45 +++++++++++++++++++ components/waves/drops/reaction-utils.ts | 24 ++++++++++ services/api/common-api.ts | 5 +++ 5 files changed, 103 insertions(+), 6 deletions(-) diff --git a/__tests__/components/waves/drops/WaveDropReactions.test.tsx b/__tests__/components/waves/drops/WaveDropReactions.test.tsx index 06144c2fca..433e8a41c4 100644 --- a/__tests__/components/waves/drops/WaveDropReactions.test.tsx +++ b/__tests__/components/waves/drops/WaveDropReactions.test.tsx @@ -72,19 +72,22 @@ const createStructuredReactionError = ({ body, message = "technical error", status, + statusText, }: { body?: unknown; message?: string; status?: number; + statusText?: string; }): Error & { status?: number; - response: { body?: unknown; status?: number }; + response: { body?: unknown; status?: number; statusText?: string }; } => Object.assign(new Error(message), { ...(status !== undefined ? { status } : {}), response: { ...(body !== undefined ? { body } : {}), ...(status !== undefined ? { status } : {}), + ...(statusText !== undefined ? { statusText } : {}), }, }); @@ -374,8 +377,9 @@ describe("WaveDropReactions", () => { (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( createStructuredReactionError({ body: " ", - message: "Not Found", + message: " ", status: 404, + statusText: "Not Found", }) ); diff --git a/__tests__/components/waves/drops/reaction-utils.test.ts b/__tests__/components/waves/drops/reaction-utils.test.ts index 29bbcd91c6..c3ca908059 100644 --- a/__tests__/components/waves/drops/reaction-utils.test.ts +++ b/__tests__/components/waves/drops/reaction-utils.test.ts @@ -4,19 +4,22 @@ const createStructuredReactionError = ({ body, message = "technical error", status, + statusText, }: { body?: unknown; message?: string; status?: number; + statusText?: string; }): Error & { status?: number; - response: { body?: unknown; status?: number }; + response: { body?: unknown; status?: number; statusText?: string }; } => Object.assign(new Error(message), { ...(status !== undefined ? { status } : {}), response: { ...(body !== undefined ? { body } : {}), ...(status !== undefined ? { status } : {}), + ...(statusText !== undefined ? { statusText } : {}), }, }); @@ -93,37 +96,53 @@ describe("getReactionErrorMessage", () => { body: " ", message: " ", status: 429, + statusText: "Too Many Requests", }), "Error adding reaction" ) ).toBe("Too Many Requests"); }); - it("surfaces the structured error message when the structured body is missing", () => { + it("surfaces the structured status text when the structured body is missing", () => { expect( getReactionErrorMessage( createStructuredReactionError({ message: "Service Unavailable", status: 503, + statusText: "Service Unavailable", }), "Error adding reaction" ) ).toBe("Service Unavailable"); }); - it("surfaces the structured error message when the structured body is blank", () => { + it("surfaces the structured status text when the structured body is blank", () => { expect( getReactionErrorMessage( createStructuredReactionError({ body: " ", - message: "Not Found", + message: " ", status: 404, + statusText: "Not Found", }), "Error adding reaction" ) ).toBe("Not Found"); }); + it("falls back to the structured error message when status text is unavailable", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + body: " ", + message: "Service Unavailable", + status: 503, + }), + "Error adding reaction" + ) + ).toBe("Service Unavailable"); + }); + it("falls back for generic network errors", () => { expect( getReactionErrorMessage( diff --git a/__tests__/services/common-api.test.ts b/__tests__/services/common-api.test.ts index 971f7d91e6..9489490834 100644 --- a/__tests__/services/common-api.test.ts +++ b/__tests__/services/common-api.test.ts @@ -135,6 +135,7 @@ describe("commonApiPost", () => { response: { status: number; headers: Headers; + statusText?: string; body?: unknown; }; } | null = null; @@ -153,6 +154,7 @@ describe("commonApiPost", () => { response: { status: number; headers: Headers; + statusText?: string; body?: unknown; }; }; @@ -165,9 +167,52 @@ describe("commonApiPost", () => { expect(error?.headers.get("retry-after")).toBe("2"); expect(error?.response.status).toBe(429); expect(error?.response.headers).toBe(responseHeaders); + expect(error?.response.statusText).toBe("Too Many Requests"); expect(error?.response.body).toBe('{"error":"rate limited"}'); }); + it("preserves statusText in structured errors when the response body is whitespace only", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 503, + statusText: "Service Unavailable", + text: async () => " ", + }); + + let error: { + message: string; + response: { + status: number; + headers: Headers; + statusText?: string; + body?: unknown; + }; + } | null = null; + + try { + await commonApiPost({ + endpoint: "e", + body: {}, + errorMode: "structured", + }); + } catch (caught) { + error = caught as { + message: string; + response: { + status: number; + headers: Headers; + statusText?: string; + body?: unknown; + }; + }; + } + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toBe(" "); + expect(error?.response.body).toBe(" "); + expect(error?.response.statusText).toBe("Service Unavailable"); + }); + it("prefers message when error key is missing", async () => { fetchMock.mockResolvedValue({ ok: false, diff --git a/components/waves/drops/reaction-utils.ts b/components/waves/drops/reaction-utils.ts index c559b92abd..f3f591cecb 100644 --- a/components/waves/drops/reaction-utils.ts +++ b/components/waves/drops/reaction-utils.ts @@ -112,6 +112,7 @@ type StructuredReactionError = Error & { status?: unknown; response?: { status?: unknown; + statusText?: unknown; body?: unknown; }; }; @@ -204,6 +205,23 @@ const getEmptyStructuredReactionStatusMessage = ( } }; +const getEmptyStructuredReactionStatusText = ( + error: StructuredReactionError +): string | null => { + const statusText = error.response?.statusText; + if (typeof statusText !== "string") { + return null; + } + + const trimmedStatusText = statusText.trim(); + + if (trimmedStatusText.length === 0) { + return null; + } + + return trimmedStatusText; +}; + const getEmptyStructuredReactionFallbackMessage = ( error: StructuredReactionError ): string | null => { @@ -243,6 +261,12 @@ export const getReactionErrorMessage = ( return statusMessage; } + const statusText = + getEmptyStructuredReactionStatusText(structuredError); + if (statusText) { + return statusText; + } + const fallbackMessage = getEmptyStructuredReactionFallbackMessage(structuredError); if (fallbackMessage) { diff --git a/services/api/common-api.ts b/services/api/common-api.ts index cb6947a84a..20d94703e2 100644 --- a/services/api/common-api.ts +++ b/services/api/common-api.ts @@ -9,6 +9,7 @@ type StructuredApiError = Error & { response: { status: number; headers: Headers; + statusText?: string; body?: unknown; }; }; @@ -63,11 +64,13 @@ const createStructuredApiError = ({ message, status, headers, + statusText, body, }: { message: string; status: number; headers: Headers; + statusText?: string; body?: unknown; }): StructuredApiError => { const error = new Error(message) as StructuredApiError; @@ -77,6 +80,7 @@ const createStructuredApiError = ({ error.response = { status, headers, + ...(statusText !== undefined ? { statusText } : {}), ...(body !== undefined ? { body } : {}), }; return error; @@ -135,6 +139,7 @@ const handleApiError = async ( message: errorMessage, status: res.status, headers: normalizeHeaders((res as { headers?: unknown }).headers), + statusText: res.statusText, body: errorBody, }); } From 1fcd272f99486e066d9f76a998ad64fd05896cb1 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 22 Apr 2026 09:51:17 +0300 Subject: [PATCH 08/15] wip Signed-off-by: Simo --- __tests__/services/common-api.test.ts | 59 ++++++++++++++++++++++++++- services/api/common-api.ts | 19 +++++++-- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/__tests__/services/common-api.test.ts b/__tests__/services/common-api.test.ts index 9489490834..2e442faaad 100644 --- a/__tests__/services/common-api.test.ts +++ b/__tests__/services/common-api.test.ts @@ -171,7 +171,7 @@ describe("commonApiPost", () => { expect(error?.response.body).toBe('{"error":"rate limited"}'); }); - it("preserves statusText in structured errors when the response body is whitespace only", async () => { + it("falls back to statusText in structured errors when the response body is whitespace only", async () => { fetchMock.mockResolvedValue({ ok: false, status: 503, @@ -208,11 +208,66 @@ describe("commonApiPost", () => { } expect(error).toBeInstanceOf(Error); - expect(error?.message).toBe(" "); + expect(error?.message).toBe("Service Unavailable"); expect(error?.response.body).toBe(" "); expect(error?.response.statusText).toBe("Service Unavailable"); }); + it("falls back to statusText when response body is whitespace only", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 503, + statusText: "Service Unavailable", + text: async () => " ", + }); + + await expect(commonApiPost({ endpoint: "e", body: {} })).rejects.toBe( + "Service Unavailable" + ); + }); + + it("falls back to statusText when parsed json fields are whitespace only", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 400, + statusText: "Bad Request", + text: async () => JSON.stringify({ error: " " }), + }); + + let error: { + message: string; + response: { + status: number; + headers: Headers; + statusText?: string; + body?: unknown; + }; + } | null = null; + + try { + await commonApiPost({ + endpoint: "e", + body: {}, + errorMode: "structured", + }); + } catch (caught) { + error = caught as { + message: string; + response: { + status: number; + headers: Headers; + statusText?: string; + body?: unknown; + }; + }; + } + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toBe("Bad Request"); + expect(error?.response.body).toBe('{"error":" "}'); + expect(error?.response.statusText).toBe("Bad Request"); + }); + it("prefers message when error key is missing", async () => { fetchMock.mockResolvedValue({ ok: false, diff --git a/services/api/common-api.ts b/services/api/common-api.ts index 20d94703e2..af6ea341b4 100644 --- a/services/api/common-api.ts +++ b/services/api/common-api.ts @@ -60,6 +60,11 @@ const normalizeHeaders = (value: unknown): Headers => { } }; +const getUsableErrorMessage = ( + message: string, + fallbackMessage: string +): string => (message.trim().length > 0 ? message : fallbackMessage); + const createStructuredApiError = ({ message, status, @@ -90,7 +95,10 @@ const handleApiError = async ( res: Response, errorMode: ApiErrorMode ): Promise => { - const fallbackErrorMessage = res.statusText || "Something went wrong"; + const fallbackErrorMessage = getUsableErrorMessage( + res.statusText, + "Something went wrong" + ); let errorMessage = fallbackErrorMessage; let errorBody: unknown = undefined; @@ -134,9 +142,14 @@ const handleApiError = async ( errorMessage = fallbackErrorMessage; } + const normalizedErrorMessage = getUsableErrorMessage( + errorMessage, + fallbackErrorMessage + ); + if (errorMode === "structured") { throw createStructuredApiError({ - message: errorMessage, + message: normalizedErrorMessage, status: res.status, headers: normalizeHeaders((res as { headers?: unknown }).headers), statusText: res.statusText, @@ -144,7 +157,7 @@ const handleApiError = async ( }); } - return Promise.reject(errorMessage); + return Promise.reject(normalizedErrorMessage); }; const executeApiRequest = async ( From 66535dc20d5ccf5850a5d98cdbee5a0e01d7e5ab Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 22 Apr 2026 10:05:08 +0300 Subject: [PATCH 09/15] wip Signed-off-by: Simo --- __tests__/services/common-api.test.ts | 97 +++++++++++++++++++++++++++ services/api/common-api.ts | 28 ++++++-- 2 files changed, 118 insertions(+), 7 deletions(-) diff --git a/__tests__/services/common-api.test.ts b/__tests__/services/common-api.test.ts index 2e442faaad..5d3664977d 100644 --- a/__tests__/services/common-api.test.ts +++ b/__tests__/services/common-api.test.ts @@ -268,6 +268,103 @@ describe("commonApiPost", () => { expect(error?.response.statusText).toBe("Bad Request"); }); + it("prefers message when error field is whitespace only", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 400, + statusText: "Bad Request", + text: async () => + JSON.stringify({ + error: " ", + message: "Name already exists", + }), + }); + + let error: { + message: string; + response: { + status: number; + headers: Headers; + statusText?: string; + body?: unknown; + }; + } | null = null; + + try { + await commonApiPost({ + endpoint: "e", + body: {}, + errorMode: "structured", + }); + } catch (caught) { + error = caught as { + message: string; + response: { + status: number; + headers: Headers; + statusText?: string; + body?: unknown; + }; + }; + } + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toBe("Name already exists"); + expect(error?.response.body).toBe( + '{"error":" ","message":"Name already exists"}' + ); + expect(error?.response.statusText).toBe("Bad Request"); + }); + + it("prefers details message when higher-priority fields are whitespace only", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 403, + statusText: "Forbidden", + text: async () => + JSON.stringify({ + error: " ", + message: " ", + details: [{ message: "Not allowed to reorder this curation" }], + }), + }); + + let error: { + message: string; + response: { + status: number; + headers: Headers; + statusText?: string; + body?: unknown; + }; + } | null = null; + + try { + await commonApiPost({ + endpoint: "e", + body: {}, + errorMode: "structured", + }); + } catch (caught) { + error = caught as { + message: string; + response: { + status: number; + headers: Headers; + statusText?: string; + body?: unknown; + }; + }; + } + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toBe("Not allowed to reorder this curation"); + expect(error?.response.body).toBe( + '{"error":" ","message":" ","details":[{"message":"Not allowed to reorder this curation"}]}' + ); + expect(error?.response.statusText).toBe("Forbidden"); + }); + it("prefers message when error key is missing", async () => { fetchMock.mockResolvedValue({ ok: false, diff --git a/services/api/common-api.ts b/services/api/common-api.ts index af6ea341b4..25447a50c9 100644 --- a/services/api/common-api.ts +++ b/services/api/common-api.ts @@ -65,6 +65,14 @@ const getUsableErrorMessage = ( fallbackMessage: string ): string => (message.trim().length > 0 ? message : fallbackMessage); +const getUsableErrorField = (value: unknown): string | undefined => { + if (typeof value !== "string") { + return undefined; + } + + return value.trim().length > 0 ? value : undefined; +}; + const createStructuredApiError = ({ message, status, @@ -124,13 +132,19 @@ const handleApiError = async ( typeof (bodyDetails[0] as { message?: unknown }).message === "string" ? (bodyDetails[0] as { message: string }).message : undefined; - - if (typeof bodyError === "string") { - errorMessage = bodyError; - } else if (typeof bodyMessage === "string") { - errorMessage = bodyMessage; - } else if (typeof detailsFirstMessage === "string") { - errorMessage = detailsFirstMessage; + const structuredErrorMessage = + getUsableErrorField(bodyError) ?? + getUsableErrorField(bodyMessage) ?? + getUsableErrorField(detailsFirstMessage); + const hasStructuredErrorField = + typeof bodyError === "string" || + typeof bodyMessage === "string" || + typeof detailsFirstMessage === "string"; + + if (structuredErrorMessage !== undefined) { + errorMessage = structuredErrorMessage; + } else if (hasStructuredErrorField) { + errorMessage = fallbackErrorMessage; } else { errorMessage = rawContent; } From 3fa8d2c55dca1bf37bc9ee2c3571636beb8688e1 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 22 Apr 2026 10:29:04 +0300 Subject: [PATCH 10/15] wip Signed-off-by: Simo --- generated/models/ApiCreateWaveConfig.ts | 16 +++---- generated/models/ApiWaveConfig.ts | 37 ++++++++++++---- generated/models/ObjectSerializer.ts | 2 +- helpers/waves/create-wave.helpers.ts | 24 +---------- helpers/waves/waves.helpers.ts | 5 +-- openapi.yaml | 56 +++++++++++++++++-------- 6 files changed, 80 insertions(+), 60 deletions(-) diff --git a/generated/models/ApiCreateWaveConfig.ts b/generated/models/ApiCreateWaveConfig.ts index fa0489df2c..1dcd6b8c62 100644 --- a/generated/models/ApiCreateWaveConfig.ts +++ b/generated/models/ApiCreateWaveConfig.ts @@ -12,16 +12,18 @@ */ import { ApiCreateNewWaveScope } from '../models/ApiCreateNewWaveScope'; -import { ApiIntRange } from '../models/ApiIntRange'; import { ApiWaveDecisionsStrategy } from '../models/ApiWaveDecisionsStrategy'; import { ApiWaveType } from '../models/ApiWaveType'; import { HttpFile } from '../http/http'; export class ApiCreateWaveConfig { 'type': ApiWaveType; - 'winning_thresholds': ApiIntRange | null; /** - * This amount of top rated drops will win. Must be set if and only if type is RANK + * Single positive threshold a drop must reach to win. Must be set if and only if type is APPROVE + */ + 'winning_threshold': number | null; + /** + * Total number of APPROVE decisions this wave may produce. Null means unlimited. Must be set if and only if type is APPROVE */ 'max_winners': number | null; /** @@ -44,10 +46,10 @@ export class ApiCreateWaveConfig { "format": "" }, { - "name": "winning_thresholds", - "baseName": "winning_thresholds", - "type": "ApiIntRange", - "format": "" + "name": "winning_threshold", + "baseName": "winning_threshold", + "type": "number", + "format": "int64" }, { "name": "max_winners", diff --git a/generated/models/ApiWaveConfig.ts b/generated/models/ApiWaveConfig.ts index 6c784f6c35..42f08796dc 100644 --- a/generated/models/ApiWaveConfig.ts +++ b/generated/models/ApiWaveConfig.ts @@ -11,7 +11,6 @@ * Do not edit the class manually. */ -import { ApiIntRange } from '../models/ApiIntRange'; import { ApiWaveDecisionsStrategy } from '../models/ApiWaveDecisionsStrategy'; import { ApiWaveScope } from '../models/ApiWaveScope'; import { ApiWaveType } from '../models/ApiWaveType'; @@ -19,9 +18,12 @@ import { HttpFile } from '../http/http'; export class ApiWaveConfig { 'type': ApiWaveType; - 'winning_thresholds': ApiIntRange | null; /** - * This amount of top rated drops will win. Must be set if and only if type is RANK + * Single positive threshold a drop must reach to win. Null for non-APPROVE waves + */ + 'winning_threshold': number | null; + /** + * Total number of APPROVE decisions this wave may produce. Null means unlimited. Null for non-APPROVE waves */ 'max_winners': number | null; /** @@ -33,6 +35,9 @@ export class ApiWaveConfig { 'decisions_strategy': ApiWaveDecisionsStrategy | null; 'next_decision_time': number | null; 'admin_drop_deletion_enabled': boolean; + 'total_no_of_decisions': number | null; + 'no_of_decisions_done': number | null; + 'no_of_decisions_left': number | null; static readonly discriminator: string | undefined = undefined; @@ -46,10 +51,10 @@ export class ApiWaveConfig { "format": "" }, { - "name": "winning_thresholds", - "baseName": "winning_thresholds", - "type": "ApiIntRange", - "format": "" + "name": "winning_threshold", + "baseName": "winning_threshold", + "type": "number", + "format": "int64" }, { "name": "max_winners", @@ -92,6 +97,24 @@ export class ApiWaveConfig { "baseName": "admin_drop_deletion_enabled", "type": "boolean", "format": "" + }, + { + "name": "total_no_of_decisions", + "baseName": "total_no_of_decisions", + "type": "number", + "format": "int64" + }, + { + "name": "no_of_decisions_done", + "baseName": "no_of_decisions_done", + "type": "number", + "format": "int64" + }, + { + "name": "no_of_decisions_left", + "baseName": "no_of_decisions_left", + "type": "number", + "format": "int64" } ]; static getAttributeTypeMap() { diff --git a/generated/models/ObjectSerializer.ts b/generated/models/ObjectSerializer.ts index c35e4520ff..330cbeaebe 100644 --- a/generated/models/ObjectSerializer.ts +++ b/generated/models/ObjectSerializer.ts @@ -526,7 +526,7 @@ import { ApiUploadsPage } from '../models/ApiUploadsPage'; import { ApiWallet } from '../models/ApiWallet'; import { ApiWave } from '../models/ApiWave'; import { ApiWaveChatConfig } from '../models/ApiWaveChatConfig'; -import { ApiWaveConfig } from '../models/ApiWaveConfig'; +import { ApiWaveConfig } from '../models/ApiWaveConfig'; import { ApiWaveContributorOverview } from '../models/ApiWaveContributorOverview'; import { ApiWaveCreditScope } from '../models/ApiWaveCreditScope'; import { ApiWaveCreditType } from '../models/ApiWaveCreditType'; diff --git a/helpers/waves/create-wave.helpers.ts b/helpers/waves/create-wave.helpers.ts index 6811dfce1e..8073c8a0f8 100644 --- a/helpers/waves/create-wave.helpers.ts +++ b/helpers/waves/create-wave.helpers.ts @@ -1,6 +1,5 @@ import type { ApiCreateNewWave } from "@/generated/models/ApiCreateNewWave"; import type { ApiCreateWaveDropRequest } from "@/generated/models/ApiCreateWaveDropRequest"; -import type { ApiIntRange } from "@/generated/models/ApiIntRange"; import { ApiWaveCreditScope } from "@/generated/models/ApiWaveCreditScope"; import { ApiWaveCreditType } from "@/generated/models/ApiWaveCreditType"; import { ApiWaveOutcomeCredit } from "@/generated/models/ApiWaveOutcomeCredit"; @@ -122,27 +121,6 @@ export const getCreateWavePreviousStep = ({ } }; -const getWinningThreshold = ({ - config, -}: { - readonly config: CreateWaveConfig; -}): ApiIntRange | null => { - const waveType = config.overview.type; - switch (waveType) { - case ApiWaveType.Approve: - return { - min: config.approval.threshold, - max: config.approval.threshold, - }; - case ApiWaveType.Rank: - case ApiWaveType.Chat: - return null; - default: - assertUnreachable(waveType); - return null; - } -}; - const getRankOutcomes = ({ config, }: { @@ -423,7 +401,7 @@ export const getCreateNewWaveBody = ({ wave: { admin_drop_deletion_enabled: config.drops.adminCanDeleteDrops, type: config.overview.type, - winning_thresholds: getWinningThreshold({ config }), + winning_threshold: config.approval.thresholdTimeMs, // TODO - should be in outcomes max_winners: null, time_lock_ms: diff --git a/helpers/waves/waves.helpers.ts b/helpers/waves/waves.helpers.ts index 94f32b0d15..b1125279e6 100644 --- a/helpers/waves/waves.helpers.ts +++ b/helpers/waves/waves.helpers.ts @@ -63,10 +63,7 @@ export const convertWaveToUpdateWave = ( wave: { admin_drop_deletion_enabled: wave.wave.admin_drop_deletion_enabled, type: wave.wave.type, - winning_thresholds: - wave.wave.winning_thresholds?.max || wave.wave.winning_thresholds?.min - ? wave.wave.winning_thresholds - : null, + winning_threshold: wave.wave.winning_threshold, max_winners: wave.wave.max_winners, time_lock_ms: wave.wave.time_lock_ms, admin_group: { diff --git a/openapi.yaml b/openapi.yaml index fcf0a0fdb9..db3811155b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -8274,7 +8274,7 @@ components: required: - type - time_lock_ms - - winning_thresholds + - winning_threshold - max_winners - admin_group - decisions_strategy @@ -8282,17 +8282,18 @@ components: properties: type: $ref: "#/components/schemas/ApiWaveType" - winning_thresholds: + winning_threshold: description: >- - Drops which rate tally ends up in this range will win. Must be set - if and only if type is APPROVE - anyOf: - - type: "null" - - $ref: "#/components/schemas/ApiIntRange" + Single positive threshold a drop must reach to win. Must be set if + and only if type is APPROVE + type: integer + format: int64 + minimum: 1 + nullable: true max_winners: description: >- - This amount of top rated drops will win. Must be set if and only if - type is RANK + Total number of APPROVE decisions this wave may produce. Null means + unlimited. Must be set if and only if type is APPROVE type: integer format: int64 minimum: 1 @@ -11639,27 +11640,31 @@ components: required: - type - time_lock_ms - - winning_thresholds + - winning_threshold - max_winners - admin_group - authenticated_user_eligible_for_admin - decisions_strategy - next_decision_time - admin_drop_deletion_enabled + - total_no_of_decisions + - no_of_decisions_done + - no_of_decisions_left properties: type: $ref: "#/components/schemas/ApiWaveType" - winning_thresholds: + winning_threshold: description: >- - Drops which rate tally ends up in this range will win. Must be set - if and only if type is APPROVE - anyOf: - - type: "null" - - $ref: "#/components/schemas/ApiIntRange" + Single positive threshold a drop must reach to win. Null for + non-APPROVE waves + type: integer + format: int64 + minimum: 1 + nullable: true max_winners: description: >- - This amount of top rated drops will win. Must be set if and only if - type is RANK + Total number of APPROVE decisions this wave may produce. Null means + unlimited. Null for non-APPROVE waves type: integer format: int64 minimum: 1 @@ -11687,6 +11692,21 @@ components: nullable: true admin_drop_deletion_enabled: type: boolean + total_no_of_decisions: + type: integer + format: int64 + minimum: 1 + nullable: true + no_of_decisions_done: + type: integer + format: int64 + minimum: 0 + nullable: true + no_of_decisions_left: + type: integer + format: int64 + minimum: 0 + nullable: true ApiWaveContributorOverview: required: - contributor_identity From a46bd8d8ae6e8cdf7df718f35f3aef0db5a40c2c Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 22 Apr 2026 11:33:02 +0300 Subject: [PATCH 11/15] wip Signed-off-by: Simo --- .../waves/create-wave.helpers.extra.test.ts | 57 +++- .../helpers/waves/create-wave.helpers.test.ts | 253 +++++++++++++++++- __tests__/hooks/useDropReaction.test.ts | 37 ++- .../monitoring/dropReactionMonitoring.test.ts | 4 +- helpers/waves/create-wave.helpers.ts | 67 ++--- hooks/drops/useDropReaction.ts | 12 +- utils/monitoring/dropReactionMonitoring.ts | 9 +- 7 files changed, 391 insertions(+), 48 deletions(-) diff --git a/__tests__/helpers/waves/create-wave.helpers.extra.test.ts b/__tests__/helpers/waves/create-wave.helpers.extra.test.ts index 01af864ec0..e7be5aab6f 100644 --- a/__tests__/helpers/waves/create-wave.helpers.extra.test.ts +++ b/__tests__/helpers/waves/create-wave.helpers.extra.test.ts @@ -157,7 +157,7 @@ it("calculates rolling end date correctly", () => { expect(body.voting.period.max).toBe(60); // last decision before 65 }); -it("sets winning thresholds for approve waves", () => { +it("keeps winning_threshold for approve waves", () => { const config: any = { overview: { type: ApiWaveType.Approve, name: "A" }, groups: { @@ -196,7 +196,7 @@ it("sets winning thresholds for approve waves", () => { }, }, outcomes: [], - approval: { threshold: 3, thresholdTimeMs: null }, + approval: { threshold: 3, thresholdTimeMs: 60000 }, }; const drop: any = { parts: [], @@ -205,5 +205,56 @@ it("sets winning thresholds for approve waves", () => { metadata: [], }; const body = getCreateNewWaveBody({ drop, picture: null, config }); - expect(body.wave.winning_thresholds).toEqual({ min: 3, max: 3 }); + expect(body.wave.winning_threshold).toBe(config.approval.thresholdTimeMs); +}); + +it("sets winning_threshold to null for non-approve waves", () => { + const config: any = { + overview: { type: ApiWaveType.Rank, name: "R" }, + groups: { + canView: "1", + canDrop: "2", + canVote: "3", + canChat: "4", + admin: "5", + }, + dates: { + submissionStartDate: 1, + votingStartDate: 2, + endDate: null, + firstDecisionTime: 2, + subsequentDecisions: [], + isRolling: false, + }, + drops: { + noOfApplicationsAllowedPerParticipant: 1, + requiredTypes: [], + requiredMetadata: [], + submissionStrategy: null, + terms: null, + signatureRequired: false, + adminCanDeleteDrops: false, + }, + chat: { enabled: false }, + voting: { + type: null, + category: null, + profileId: null, + timeWeighted: { + enabled: false, + averagingInterval: 5, + averagingIntervalUnit: "minutes", + }, + }, + outcomes: [], + approval: { threshold: 3, thresholdTimeMs: 60000 }, + }; + const drop: any = { + parts: [], + referenced_nfts: [], + mentioned_users: [], + metadata: [], + }; + const body = getCreateNewWaveBody({ drop, picture: null, config }); + expect(body.wave.winning_threshold).toBeNull(); }); diff --git a/__tests__/helpers/waves/create-wave.helpers.test.ts b/__tests__/helpers/waves/create-wave.helpers.test.ts index d422a5f73b..ca6399628f 100644 --- a/__tests__/helpers/waves/create-wave.helpers.test.ts +++ b/__tests__/helpers/waves/create-wave.helpers.test.ts @@ -1,14 +1,72 @@ import { + getCreateNewWaveBody, getCreateWaveNextStep, getCreateWavePreviousStep, calculateLastDecisionTime, } from "@/helpers/waves/create-wave.helpers"; +import { ApiWaveOutcomeCredit } from "@/generated/models/ApiWaveOutcomeCredit"; +import { ApiWaveOutcomeSubType } from "@/generated/models/ApiWaveOutcomeSubType"; +import { ApiWaveOutcomeType } from "@/generated/models/ApiWaveOutcomeType"; import { ApiWaveParticipationIdentitySubmissionAllowDuplicates } from "@/generated/models/ApiWaveParticipationIdentitySubmissionAllowDuplicates"; import { ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted } from "@/generated/models/ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted"; import { ApiWaveParticipationSubmissionStrategyType } from "@/generated/models/ApiWaveParticipationSubmissionStrategyType"; import { ApiWaveType } from "@/generated/models/ApiWaveType"; import { ApiWaveMetadataType } from "@/generated/models/ApiWaveMetadataType"; -import { CreateWaveStep } from "@/types/waves.types"; +import { + CreateWaveOutcomeConfigWinnersCreditValueType, + CreateWaveOutcomeType, + CreateWaveStep, +} from "@/types/waves.types"; + +const createBaseConfig = (waveType: ApiWaveType) => + ({ + overview: { type: waveType, name: "W", image: null }, + groups: { + canView: "1", + canDrop: "2", + canVote: "3", + canChat: "4", + admin: "5", + }, + dates: { + submissionStartDate: 1, + votingStartDate: 2, + endDate: 10, + firstDecisionTime: 2, + subsequentDecisions: [], + isRolling: false, + }, + drops: { + noOfApplicationsAllowedPerParticipant: 1, + requiredTypes: [], + requiredMetadata: [], + submissionStrategy: null, + terms: null, + signatureRequired: false, + adminCanDeleteDrops: false, + }, + chat: { enabled: true }, + voting: { + type: null, + category: null, + profileId: null, + timeWeighted: { + enabled: false, + averagingInterval: 5, + averagingIntervalUnit: "minutes", + }, + }, + outcomes: [], + approval: { threshold: null, thresholdTimeMs: null }, + }) as any; + +const createDrop = () => + ({ + parts: [], + referenced_nfts: [], + mentioned_users: [], + metadata: [], + }) as any; describe("create-wave.helpers", () => { describe("getCreateWaveNextStep", () => { @@ -78,9 +136,6 @@ describe("create-wave.helpers", () => { describe("getCreateNewWaveBody", () => { it("converts config into request body", () => { - const { - getCreateNewWaveBody, - } = require("@/helpers/waves/create-wave.helpers"); const config = { overview: { type: ApiWaveType.Chat, name: "W", image: null }, groups: { @@ -138,9 +193,6 @@ describe("create-wave.helpers", () => { }); it("includes identity submission strategy when configured", () => { - const { - getCreateNewWaveBody, - } = require("@/helpers/waves/create-wave.helpers"); const config = { overview: { type: ApiWaveType.Rank, name: "W", image: null }, groups: { @@ -200,5 +252,192 @@ describe("create-wave.helpers", () => { config.drops.submissionStrategy ); }); + + it("keeps manual approve outcomes without max winners and filters invalid credit amounts", () => { + const config = createBaseConfig(ApiWaveType.Approve); + config.outcomes = [ + { + type: CreateWaveOutcomeType.MANUAL, + title: "Manual action", + credit: null, + category: null, + maxWinners: null, + winnersConfig: null, + }, + { + type: CreateWaveOutcomeType.REP, + title: null, + credit: 0, + category: "gold", + maxWinners: null, + winnersConfig: null, + }, + { + type: CreateWaveOutcomeType.REP, + title: null, + credit: Number.NaN, + category: "gold", + maxWinners: null, + winnersConfig: null, + }, + { + type: CreateWaveOutcomeType.REP, + title: null, + credit: 15, + category: "gold", + maxWinners: null, + winnersConfig: null, + }, + { + type: CreateWaveOutcomeType.NIC, + title: null, + credit: null, + category: null, + maxWinners: null, + winnersConfig: null, + }, + { + type: CreateWaveOutcomeType.NIC, + title: null, + credit: 0, + category: null, + maxWinners: null, + winnersConfig: null, + }, + { + type: CreateWaveOutcomeType.NIC, + title: null, + credit: 25, + category: null, + maxWinners: null, + winnersConfig: null, + }, + ] as any; + + const res = getCreateNewWaveBody({ + drop: createDrop(), + picture: null, + config, + }); + + expect(res.outcomes).toEqual([ + { + type: ApiWaveOutcomeType.Manual, + description: "Manual action", + }, + { + type: ApiWaveOutcomeType.Automatic, + subtype: ApiWaveOutcomeSubType.CreditDistribution, + description: "", + credit: ApiWaveOutcomeCredit.Rep, + rep_category: "gold", + amount: 15, + }, + { + type: ApiWaveOutcomeType.Automatic, + subtype: ApiWaveOutcomeSubType.CreditDistribution, + description: "", + credit: ApiWaveOutcomeCredit.Cic, + amount: 25, + }, + ]); + }); + + it("filters rank outcomes with missing or non-positive total amounts", () => { + const config = createBaseConfig(ApiWaveType.Rank); + config.outcomes = [ + { + type: CreateWaveOutcomeType.REP, + title: null, + credit: null, + category: "gold", + maxWinners: 1, + winnersConfig: { + creditValueType: + CreateWaveOutcomeConfigWinnersCreditValueType.ABSOLUTE_VALUE, + totalAmount: 0, + winners: [{ value: 10 }], + }, + }, + { + type: CreateWaveOutcomeType.REP, + title: null, + credit: null, + category: "gold", + maxWinners: 1, + winnersConfig: { + creditValueType: + CreateWaveOutcomeConfigWinnersCreditValueType.ABSOLUTE_VALUE, + totalAmount: Number.NaN, + winners: [{ value: 10 }], + }, + }, + { + type: CreateWaveOutcomeType.REP, + title: null, + credit: null, + category: "gold", + maxWinners: 1, + winnersConfig: { + creditValueType: + CreateWaveOutcomeConfigWinnersCreditValueType.ABSOLUTE_VALUE, + totalAmount: 10, + winners: [{ value: 10 }], + }, + }, + { + type: CreateWaveOutcomeType.NIC, + title: null, + credit: null, + category: null, + maxWinners: 1, + winnersConfig: { + creditValueType: + CreateWaveOutcomeConfigWinnersCreditValueType.ABSOLUTE_VALUE, + totalAmount: null, + winners: [{ value: 20 }], + }, + }, + { + type: CreateWaveOutcomeType.NIC, + title: null, + credit: null, + category: null, + maxWinners: 1, + winnersConfig: { + creditValueType: + CreateWaveOutcomeConfigWinnersCreditValueType.ABSOLUTE_VALUE, + totalAmount: 20, + winners: [{ value: 20 }], + }, + }, + ] as any; + + const res = getCreateNewWaveBody({ + drop: createDrop(), + picture: null, + config, + }); + + expect(res.outcomes).toEqual([ + { + type: ApiWaveOutcomeType.Automatic, + subtype: ApiWaveOutcomeSubType.CreditDistribution, + description: "Rep distribution", + credit: ApiWaveOutcomeCredit.Rep, + rep_category: "gold", + amount: 10, + distribution: [{ amount: 10, description: null }], + }, + { + type: ApiWaveOutcomeType.Automatic, + subtype: ApiWaveOutcomeSubType.CreditDistribution, + description: "NIC distribution", + credit: ApiWaveOutcomeCredit.Cic, + amount: 20, + distribution: [{ amount: 20, description: null }], + }, + ]); + }); }); }); diff --git a/__tests__/hooks/useDropReaction.test.ts b/__tests__/hooks/useDropReaction.test.ts index 6fab7f735c..59da6a00fd 100644 --- a/__tests__/hooks/useDropReaction.test.ts +++ b/__tests__/hooks/useDropReaction.test.ts @@ -5,10 +5,14 @@ import { ApiDropType } from "@/generated/models/ApiDropType"; import { useAuth } from "@/components/auth/Auth"; import { useMyStream } from "@/contexts/wave/MyStreamContext"; import * as commonApi from "@/services/api/common-api"; +import * as dropReactionMonitoring from "@/utils/monitoring/dropReactionMonitoring"; import { act, renderHook } from "@testing-library/react"; const setToastMock = jest.fn(); -const applyOptimisticDropUpdateMock = jest.fn(() => ({ rollback: jest.fn() })); +const rollbackMock = jest.fn(); +const applyOptimisticDropUpdateMock = jest.fn(() => ({ + rollback: rollbackMock, +})); jest.mock("@/components/auth/Auth", () => ({ useAuth: jest.fn(), @@ -140,6 +144,37 @@ describe("useDropReaction", () => { }); }); + it("does not treat a throwing onSuccess callback as a request failure", async () => { + const onSuccess = jest.fn(() => { + throw new Error("consumer callback failed"); + }); + (commonApi.commonApiPost as jest.Mock).mockResolvedValueOnce({}); + + const { result } = renderHook(() => + useDropReaction(mockDrop, { + source: "quick-react", + onSuccess, + }) + ); + + await act(async () => { + await result.current.react(":smile:"); + }); + + expect(onSuccess).toHaveBeenCalledTimes(1); + expect( + dropReactionMonitoring.recordReactionRequestSucceeded + ).toHaveBeenCalledTimes(1); + expect( + dropReactionMonitoring.recordReactionRequestFailed + ).not.toHaveBeenCalled(); + expect( + dropReactionMonitoring.recordReactionRollbackApplied + ).not.toHaveBeenCalled(); + expect(setToastMock).not.toHaveBeenCalled(); + expect(rollbackMock).not.toHaveBeenCalled(); + }); + it("falls back for unsafe structured quick react failures", async () => { (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( createStructuredReactionError({ diff --git a/__tests__/utils/monitoring/dropReactionMonitoring.test.ts b/__tests__/utils/monitoring/dropReactionMonitoring.test.ts index cc8bf59ca5..a3342c1c3f 100644 --- a/__tests__/utils/monitoring/dropReactionMonitoring.test.ts +++ b/__tests__/utils/monitoring/dropReactionMonitoring.test.ts @@ -192,7 +192,7 @@ describe("dropReactionMonitoring", () => { ); }); - it("captures reconciliation mismatch after success when failure timestamp is undefined", () => { + it("captures reconciliation mismatch after success when failure timestamp is null", () => { const mutation = beginReactionMutation({ dropId: "drop-4", waveId: "wave-1", @@ -212,7 +212,7 @@ describe("dropReactionMonitoring", () => { dateNowSpy.mockReturnValue(1_100); recordReactionRequestSucceeded(mutation); - expect((mutation as any).apiFailedAt).toBeUndefined(); + expect(mutation.apiFailedAt).toBeNull(); dateNowSpy.mockReturnValue(1_500); recordReactionRealtimeReconciliation({ diff --git a/helpers/waves/create-wave.helpers.ts b/helpers/waves/create-wave.helpers.ts index 8073c8a0f8..ab82f1e417 100644 --- a/helpers/waves/create-wave.helpers.ts +++ b/helpers/waves/create-wave.helpers.ts @@ -43,6 +43,11 @@ const getTimeWeightedLockMs = ( return Math.max(MIN_MS, Math.min(MAX_MS, ms)); }; +const isPositiveFiniteNumber = ( + value: number | null | undefined +): value is number => + typeof value === "number" && Number.isFinite(value) && value > 0; + export const getCreateWaveNextStep = ({ step, waveType, @@ -128,15 +133,17 @@ const getRankOutcomes = ({ }): ApiCreateWaveOutcome[] => { const outcomes: ApiCreateWaveOutcome[] = []; for (const outcome of config.outcomes) { + const winnersConfig = outcome.winnersConfig; + if ( outcome.type === CreateWaveOutcomeType.MANUAL && outcome.title && - outcome.winnersConfig + winnersConfig ) { outcomes.push({ type: ApiWaveOutcomeType.Manual, description: outcome.title, - distribution: outcome.winnersConfig.winners.map((winner) => ({ + distribution: winnersConfig.winners.map((winner) => ({ amount: winner.value, description: outcome.title, })), @@ -144,7 +151,8 @@ const getRankOutcomes = ({ } else if ( outcome.type === CreateWaveOutcomeType.REP && outcome.category && - outcome.winnersConfig?.totalAmount + winnersConfig && + isPositiveFiniteNumber(winnersConfig.totalAmount) ) { outcomes.push({ type: ApiWaveOutcomeType.Automatic, @@ -152,23 +160,24 @@ const getRankOutcomes = ({ description: "Rep distribution", credit: ApiWaveOutcomeCredit.Rep, rep_category: outcome.category, - amount: outcome.winnersConfig.totalAmount, - distribution: outcome.winnersConfig.winners.map((winner) => ({ + amount: winnersConfig.totalAmount, + distribution: winnersConfig.winners.map((winner) => ({ amount: winner.value, description: null, })), }); } else if ( outcome.type === CreateWaveOutcomeType.NIC && - outcome.winnersConfig?.totalAmount + winnersConfig && + isPositiveFiniteNumber(winnersConfig.totalAmount) ) { outcomes.push({ type: ApiWaveOutcomeType.Automatic, subtype: ApiWaveOutcomeSubType.CreditDistribution, description: "NIC distribution", credit: ApiWaveOutcomeCredit.Cic, - amount: outcome.winnersConfig.totalAmount, - distribution: outcome.winnersConfig.winners.map((winner) => ({ + amount: winnersConfig.totalAmount, + distribution: winnersConfig.winners.map((winner) => ({ amount: winner.value, description: null, })), @@ -185,11 +194,7 @@ const getApproveOutcomes = ({ }): ApiCreateWaveOutcome[] => { const outcomes: ApiCreateWaveOutcome[] = []; for (const outcome of config.outcomes) { - if ( - outcome.type === CreateWaveOutcomeType.MANUAL && - outcome.title && - outcome.maxWinners - ) { + if (outcome.type === CreateWaveOutcomeType.MANUAL && outcome.title) { outcomes.push({ type: ApiWaveOutcomeType.Manual, description: outcome.title, @@ -197,7 +202,7 @@ const getApproveOutcomes = ({ } else if ( outcome.type === CreateWaveOutcomeType.REP && outcome.category && - outcome.credit + isPositiveFiniteNumber(outcome.credit) ) { outcomes.push({ type: ApiWaveOutcomeType.Automatic, @@ -207,7 +212,10 @@ const getApproveOutcomes = ({ rep_category: outcome.category, amount: outcome.credit, }); - } else if (outcome.type === CreateWaveOutcomeType.NIC && outcome.credit) { + } else if ( + outcome.type === CreateWaveOutcomeType.NIC && + isPositiveFiniteNumber(outcome.credit) + ) { outcomes.push({ type: ApiWaveOutcomeType.Automatic, subtype: ApiWaveOutcomeSubType.CreditDistribution, @@ -313,23 +321,17 @@ const calculateEndDate = (dates: CreateWaveDatesConfig): number => { ); } - // If isRolling is true, we need to calculate the last decision time - if (dates.isRolling) { - // Need an end date for rolling waves - if (typeof dates.endDate !== "number") { - throw new Error("End date must be explicitly set when isRolling is true"); - } - - // Calculate the last decision time that will occur before the user-specified end date - return calculateLastDecisionTime( - dates.firstDecisionTime, - dates.subsequentDecisions, - dates.endDate - ); + // If we reach this point, isRolling is true and we need to calculate the last decision time + if (typeof dates.endDate !== "number") { + throw new Error("End date must be explicitly set when isRolling is true"); } - // This should never happen if all cases are covered - return dates.endDate ?? dates.firstDecisionTime; + // Calculate the last decision time that will occur before the user-specified end date + return calculateLastDecisionTime( + dates.firstDecisionTime, + dates.subsequentDecisions, + dates.endDate + ); }; export const getCreateNewWaveBody = ({ @@ -401,7 +403,10 @@ export const getCreateNewWaveBody = ({ wave: { admin_drop_deletion_enabled: config.drops.adminCanDeleteDrops, type: config.overview.type, - winning_threshold: config.approval.thresholdTimeMs, + winning_threshold: + config.overview.type === ApiWaveType.Approve + ? config.approval.thresholdTimeMs + : null, // TODO - should be in outcomes max_winners: null, time_lock_ms: diff --git a/hooks/drops/useDropReaction.ts b/hooks/drops/useDropReaction.ts index ba02ed7777..c920afc48f 100644 --- a/hooks/drops/useDropReaction.ts +++ b/hooks/drops/useDropReaction.ts @@ -164,6 +164,8 @@ export function useDropReaction( recordReaction(reactionCode); } + let succeeded = false; + try { const endpoint = `drops/${drop.id}/reaction`; if (isRemoving) { @@ -188,7 +190,7 @@ export function useDropReaction( } recordReactionRequestSucceeded(mutation); rollbackRef.current = null; - onSuccess?.(); + succeeded = true; } catch (error) { recordReactionRequestFailed(mutation, error); const errorMessage = getReactionErrorMessage( @@ -200,6 +202,14 @@ export function useDropReaction( recordReactionRollbackApplied(mutation); rollbackRef.current = null; } + + if (succeeded) { + try { + onSuccess?.(); + } catch { + // Ignore consumer callback errors so a successful request stays successful. + } + } }, [ canReact, diff --git a/utils/monitoring/dropReactionMonitoring.ts b/utils/monitoring/dropReactionMonitoring.ts index 5a12aa4f87..d3e9c4e114 100644 --- a/utils/monitoring/dropReactionMonitoring.ts +++ b/utils/monitoring/dropReactionMonitoring.ts @@ -41,9 +41,9 @@ interface ReactionMutationContext { readonly websocketStatus: WebSocketStatus | null; endpoint?: string | null; method?: string | null; - requestSentAt?: number | null; - apiSucceededAt?: number | null; - apiFailedAt?: number | null; + requestSentAt: number | null; + apiSucceededAt: number | null; + apiFailedAt: number | null; failureCaptured?: boolean; supersededByMutationId?: string | null; } @@ -422,6 +422,9 @@ export function beginReactionMutation(params: { visibilityState: getVisibilityState(), online: getOnlineStatus(), websocketStatus: toWebsocketStatus(params.websocketStatus), + requestSentAt: null, + apiSucceededAt: null, + apiFailedAt: null, }; latestMutationIdByDrop.set(params.dropId, context.mutationId); From ba98404690efbe77cc1dff24152a0664a255031f Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 22 Apr 2026 12:03:44 +0300 Subject: [PATCH 12/15] wip Signed-off-by: Simo --- .../waves/drops/reaction-utils.test.ts | 42 ++++++++++ .../waves/create-wave.helpers.extra.test.ts | 2 +- .../monitoring/dropReactionMonitoring.test.ts | 82 ++++++++++++++++++- components/waves/drops/reaction-utils.ts | 14 +++- helpers/waves/create-wave.helpers.ts | 2 +- utils/monitoring/dropReactionMonitoring.ts | 1 + 6 files changed, 136 insertions(+), 7 deletions(-) diff --git a/__tests__/components/waves/drops/reaction-utils.test.ts b/__tests__/components/waves/drops/reaction-utils.test.ts index c3ca908059..e2e595f689 100644 --- a/__tests__/components/waves/drops/reaction-utils.test.ts +++ b/__tests__/components/waves/drops/reaction-utils.test.ts @@ -130,6 +130,48 @@ describe("getReactionErrorMessage", () => { ).toBe("Not Found"); }); + it("surfaces the structured status text when the parsed json body has no known fields", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + body: JSON.stringify({ foo: "bar" }), + message: "Bad Request", + status: 400, + statusText: "Bad Request", + }), + "Error adding reaction" + ) + ).toBe("Bad Request"); + }); + + it("surfaces the structured status text when parsed json known fields are blank", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + body: JSON.stringify({ error: " " }), + message: "Bad Request", + status: 400, + statusText: "Bad Request", + }), + "Error adding reaction" + ) + ).toBe("Bad Request"); + }); + + it("surfaces the structured status text when an object body has no known fields", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + body: { foo: "bar" }, + message: "Bad Request", + status: 400, + statusText: "Bad Request", + }), + "Error adding reaction" + ) + ).toBe("Bad Request"); + }); + it("falls back to the structured error message when status text is unavailable", () => { expect( getReactionErrorMessage( diff --git a/__tests__/helpers/waves/create-wave.helpers.extra.test.ts b/__tests__/helpers/waves/create-wave.helpers.extra.test.ts index e7be5aab6f..3dc61d0da0 100644 --- a/__tests__/helpers/waves/create-wave.helpers.extra.test.ts +++ b/__tests__/helpers/waves/create-wave.helpers.extra.test.ts @@ -205,7 +205,7 @@ it("keeps winning_threshold for approve waves", () => { metadata: [], }; const body = getCreateNewWaveBody({ drop, picture: null, config }); - expect(body.wave.winning_threshold).toBe(config.approval.thresholdTimeMs); + expect(body.wave.winning_threshold).toBe(config.approval.threshold); }); it("sets winning_threshold to null for non-approve waves", () => { diff --git a/__tests__/utils/monitoring/dropReactionMonitoring.test.ts b/__tests__/utils/monitoring/dropReactionMonitoring.test.ts index a3342c1c3f..ff3522a96f 100644 --- a/__tests__/utils/monitoring/dropReactionMonitoring.test.ts +++ b/__tests__/utils/monitoring/dropReactionMonitoring.test.ts @@ -282,7 +282,7 @@ describe("dropReactionMonitoring", () => { expect(captureExceptionMock).not.toHaveBeenCalled(); }); - it("dedupes repeated identical failure events within 60 seconds", () => { + it("resets the per-drop sequence when the last tracked mutation ages out", () => { const firstMutation = beginReactionMutation({ dropId: "drop-6", waveId: "wave-1", @@ -294,13 +294,87 @@ describe("dropReactionMonitoring", () => { profileId: "profile-1", websocketStatus: WebSocketStatus.CONNECTED, }); + + dateNowSpy.mockReturnValue(302_000); + const nextMutation = beginReactionMutation({ + dropId: "drop-6", + waveId: "wave-1", + source: "picker", + action: "replace", + previousReaction: ":smile:", + intendedReaction: ":wave:", + optimisticReaction: ":wave:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + + expect(firstMutation.dropMutationSeq).toBe(1); + expect(nextMutation.dropMutationSeq).toBe(1); + }); + + it("keeps the per-drop sequence while a newer mutation is still tracked", () => { + const oldestMutation = beginReactionMutation({ + dropId: "drop-7", + waveId: "wave-1", + source: "picker", + action: "add", + previousReaction: null, + intendedReaction: ":smile:", + optimisticReaction: ":smile:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + + dateNowSpy.mockReturnValue(150_000); + const newerMutation = beginReactionMutation({ + dropId: "drop-7", + waveId: "wave-1", + source: "picker", + action: "replace", + previousReaction: ":smile:", + intendedReaction: ":wave:", + optimisticReaction: ":wave:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + + dateNowSpy.mockReturnValue(302_000); + const latestMutation = beginReactionMutation({ + dropId: "drop-7", + waveId: "wave-1", + source: "picker", + action: "replace", + previousReaction: ":wave:", + intendedReaction: ":fire:", + optimisticReaction: ":fire:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + + expect(oldestMutation.dropMutationSeq).toBe(1); + expect(newerMutation.dropMutationSeq).toBe(2); + expect(latestMutation.dropMutationSeq).toBe(3); + }); + + it("dedupes repeated identical failure events within 60 seconds", () => { + const firstMutation = beginReactionMutation({ + dropId: "drop-8", + waveId: "wave-1", + source: "picker", + action: "add", + previousReaction: null, + intendedReaction: ":smile:", + optimisticReaction: ":smile:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); recordReactionRequestSent(firstMutation, { - endpoint: "drops/drop-6/reaction", + endpoint: "drops/drop-8/reaction", method: "POST", }); const secondMutation = beginReactionMutation({ - dropId: "drop-6", + dropId: "drop-8", waveId: "wave-1", source: "picker", action: "add", @@ -311,7 +385,7 @@ describe("dropReactionMonitoring", () => { websocketStatus: WebSocketStatus.CONNECTED, }); recordReactionRequestSent(secondMutation, { - endpoint: "drops/drop-6/reaction", + endpoint: "drops/drop-8/reaction", method: "POST", }); diff --git a/components/waves/drops/reaction-utils.ts b/components/waves/drops/reaction-utils.ts index b9bac0a01f..1624c6e373 100644 --- a/components/waves/drops/reaction-utils.ts +++ b/components/waves/drops/reaction-utils.ts @@ -173,7 +173,19 @@ const hasNoStructuredReactionBody = (body: unknown): boolean => { return true; } - return typeof body === "string" && body.trim().length === 0; + if (typeof body === "string") { + if (body.trim().length === 0) { + return true; + } + + try { + return hasNoStructuredReactionBody(JSON.parse(body) as unknown); + } catch { + return false; + } + } + + return getStructuredReactionBodyMessage(body) === null; }; const getStructuredReactionStatus = ( diff --git a/helpers/waves/create-wave.helpers.ts b/helpers/waves/create-wave.helpers.ts index ab82f1e417..8bf1ced170 100644 --- a/helpers/waves/create-wave.helpers.ts +++ b/helpers/waves/create-wave.helpers.ts @@ -405,7 +405,7 @@ export const getCreateNewWaveBody = ({ type: config.overview.type, winning_threshold: config.overview.type === ApiWaveType.Approve - ? config.approval.thresholdTimeMs + ? config.approval.threshold : null, // TODO - should be in outcomes max_winners: null, diff --git a/utils/monitoring/dropReactionMonitoring.ts b/utils/monitoring/dropReactionMonitoring.ts index d3e9c4e114..0f5ffc79c6 100644 --- a/utils/monitoring/dropReactionMonitoring.ts +++ b/utils/monitoring/dropReactionMonitoring.ts @@ -68,6 +68,7 @@ function pruneState(now: number): void { mutationContextById.delete(mutationId); if (latestMutationIdByDrop.get(context.dropId) === mutationId) { latestMutationIdByDrop.delete(context.dropId); + dropMutationSeqByDrop.delete(context.dropId); } } } From df78d58840dc1ad25b1c6fc9b2fc19284db1cd16 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 22 Apr 2026 12:22:27 +0300 Subject: [PATCH 13/15] wip Signed-off-by: Simo --- utils/monitoring/dropReactionMonitoring.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/utils/monitoring/dropReactionMonitoring.ts b/utils/monitoring/dropReactionMonitoring.ts index 0f5ffc79c6..197e4d08e6 100644 --- a/utils/monitoring/dropReactionMonitoring.ts +++ b/utils/monitoring/dropReactionMonitoring.ts @@ -12,7 +12,6 @@ const REACTION_REQUEST_OPERATION = "reaction-request"; const REACTION_ANOMALY_OPERATION = "reaction-anomaly"; const ANOMALY_OUT_OF_ORDER = "out-of-order"; const ANOMALY_OPTIMISTIC_REVERTED = "optimistic-reverted"; -const ANOMALY_REVERTED_AFTER_SUCCESS = "reverted-after-success"; export type ReactionSource = "quick-react" | "picker" | "chip"; type ReactionAction = "add" | "remove" | "replace"; @@ -616,7 +615,7 @@ export function recordReactionRealtimeReconciliation(params: { "Reaction optimistic state disagreed with canonical state" ), level: "warning", - fingerprint: [REACTION_FEATURE, ANOMALY_REVERTED_AFTER_SUCCESS], + fingerprint: [REACTION_FEATURE, ANOMALY_OPTIMISTIC_REVERTED], tags: { feature: REACTION_FEATURE, operation: REACTION_ANOMALY_OPERATION, From 52eb9f92d97404ff19b8908f2245ce10f32de618 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 22 Apr 2026 13:21:35 +0300 Subject: [PATCH 14/15] wip Signed-off-by: Simo --- .../waves/drops/reaction-utils.test.ts | 47 +++++++++++++++++++ components/waves/drops/reaction-utils.ts | 20 +++++--- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/__tests__/components/waves/drops/reaction-utils.test.ts b/__tests__/components/waves/drops/reaction-utils.test.ts index e2e595f689..ca5f2f0d00 100644 --- a/__tests__/components/waves/drops/reaction-utils.test.ts +++ b/__tests__/components/waves/drops/reaction-utils.test.ts @@ -64,6 +64,53 @@ describe("getReactionErrorMessage", () => { ).toBe("Reaction not allowed"); }); + it("skips blank details messages and uses the first later valid one", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + body: JSON.stringify({ + details: [ + { message: " " }, + { message: " Reaction not allowed " }, + ], + }), + message: "unexpected raw error", + }), + "Error adding reaction" + ) + ).toBe("Reaction not allowed"); + }); + + it("skips malformed detail entries before finding a valid message", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + body: JSON.stringify({ + details: [null, "bad", {}, { message: "Retry later" }], + }), + message: "unexpected raw error", + }), + "Error adding reaction" + ) + ).toBe("Retry later"); + }); + + it("falls back when no detail entry contains a usable message", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + body: JSON.stringify({ + details: [null, { message: " " }, { message: 123 }], + }), + message: "Bad Request", + status: 400, + statusText: "Bad Request", + }), + "Error adding reaction" + ) + ).toBe("Bad Request"); + }); + it("falls back for non-JSON structured error bodies", () => { expect( getReactionErrorMessage( diff --git a/components/waves/drops/reaction-utils.ts b/components/waves/drops/reaction-utils.ts index 1624c6e373..9e856d50b6 100644 --- a/components/waves/drops/reaction-utils.ts +++ b/components/waves/drops/reaction-utils.ts @@ -127,14 +127,20 @@ const getDetailsFirstMessage = (details: unknown): string | null => { return null; } - const [firstDetail] = details; - if (firstDetail === null || typeof firstDetail !== "object") { - return null; - } + for (const detail of details) { + if (detail === null || typeof detail !== "object") { + continue; + } - const message = (firstDetail as { message?: unknown }).message; - if (typeof message === "string" && message.trim().length > 0) { - return message; + const message = (detail as { message?: unknown }).message; + if (typeof message !== "string") { + continue; + } + + const trimmedMessage = message.trim(); + if (trimmedMessage.length > 0) { + return trimmedMessage; + } } return null; From f187c4fedf05c065cf8785631ecfd922ae67cecb Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 22 Apr 2026 13:36:51 +0300 Subject: [PATCH 15/15] wip Signed-off-by: Simo --- .../waves/drops/reaction-utils.test.ts | 23 ++++++++++ components/waves/drops/reaction-utils.ts | 43 ++++++++++--------- 2 files changed, 45 insertions(+), 21 deletions(-) diff --git a/__tests__/components/waves/drops/reaction-utils.test.ts b/__tests__/components/waves/drops/reaction-utils.test.ts index ca5f2f0d00..49098ffc63 100644 --- a/__tests__/components/waves/drops/reaction-utils.test.ts +++ b/__tests__/components/waves/drops/reaction-utils.test.ts @@ -136,6 +136,18 @@ describe("getReactionErrorMessage", () => { ).toBe("Unauthorized"); }); + it("maps unauthorized status when response is null", () => { + expect( + getReactionErrorMessage( + Object.assign(new Error("Something went wrong"), { + status: 401, + response: null, + }), + "Error adding reaction" + ) + ).toBe("Unauthorized"); + }); + it("maps rate-limit status when the structured body is blank", () => { expect( getReactionErrorMessage( @@ -232,6 +244,17 @@ describe("getReactionErrorMessage", () => { ).toBe("Service Unavailable"); }); + it("falls back to the structured error message when response is missing", () => { + expect( + getReactionErrorMessage( + Object.assign(new Error("Service Unavailable"), { + status: 503, + }), + "Error adding reaction" + ) + ).toBe("Service Unavailable"); + }); + it("falls back for generic network errors", () => { expect( getReactionErrorMessage( diff --git a/components/waves/drops/reaction-utils.ts b/components/waves/drops/reaction-utils.ts index 9e856d50b6..ddfce438c6 100644 --- a/components/waves/drops/reaction-utils.ts +++ b/components/waves/drops/reaction-utils.ts @@ -116,7 +116,7 @@ type StructuredReactionError = Error & { status?: unknown; statusText?: unknown; body?: unknown; - }; + } | null; }; const isNonEmptyUnknownArray = (value: unknown): value is readonly unknown[] => @@ -266,34 +266,35 @@ export const getReactionErrorMessage = ( if (error !== null && typeof error === "object") { const structuredError = error as StructuredReactionError; const structuredResponse = structuredError.response; - if (structuredResponse !== undefined) { + if (structuredResponse !== null && structuredResponse !== undefined) { const structuredBody = structuredResponse.body; const safeMessage = getStructuredReactionBodyMessage(structuredBody); if (safeMessage) { return safeMessage; } - if (hasNoStructuredReactionBody(structuredBody)) { - const statusMessage = getEmptyStructuredReactionStatusMessage( - getStructuredReactionStatus(structuredError) - ); - if (statusMessage) { - return statusMessage; - } - - const statusText = - getEmptyStructuredReactionStatusText(structuredError); - if (statusText) { - return statusText; - } - - const fallbackMessage = - getEmptyStructuredReactionFallbackMessage(structuredError); - if (fallbackMessage) { - return fallbackMessage; - } + if (!hasNoStructuredReactionBody(structuredBody)) { + return fallback; } } + + const statusMessage = getEmptyStructuredReactionStatusMessage( + getStructuredReactionStatus(structuredError) + ); + if (statusMessage) { + return statusMessage; + } + + const statusText = getEmptyStructuredReactionStatusText(structuredError); + if (statusText) { + return statusText; + } + + const fallbackMessage = + getEmptyStructuredReactionFallbackMessage(structuredError); + if (fallbackMessage) { + return fallbackMessage; + } } return fallback;