diff --git a/__tests__/components/waves/drops/WaveDropActionsAddReaction.test.tsx b/__tests__/components/waves/drops/WaveDropActionsAddReaction.test.tsx
index 84e16961d9..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();
@@ -47,6 +48,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),
@@ -173,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 6e650feab5..433e8a41c4 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),
@@ -48,6 +67,29 @@ jest.mock("@/hooks/useLongPressInteraction", () => ({
const mockUseEmoji = useEmoji as jest.Mock;
const mockUseAuth = useAuth as jest.Mock;
+const setToastMock = jest.fn();
+const createStructuredReactionError = ({
+ body,
+ message = "technical error",
+ status,
+ statusText,
+}: {
+ body?: unknown;
+ message?: string;
+ status?: number;
+ statusText?: string;
+}): Error & {
+ 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 } : {}),
+ },
+ });
type NativeEmojiMock = { skins: Array<{ native: string }> };
@@ -95,7 +137,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() })),
@@ -260,6 +302,7 @@ describe("WaveDropReactions", () => {
expect(commonApi.commonApiPost).toHaveBeenCalledWith({
endpoint: "drops/test-drop/reaction",
body: { reaction: ":gm:" },
+ errorMode: "structured",
});
// Click button again to decrement
@@ -269,6 +312,99 @@ describe("WaveDropReactions", () => {
});
expect(commonApi.commonApiDelete).toHaveBeenCalledWith({
endpoint: "drops/test-drop/reaction",
+ errorMode: "structured",
+ });
+ });
+
+ 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(
+ createStructuredReactionError({
+ body: JSON.stringify({ message: "Unauthorized" }),
+ message: "unexpected raw error",
+ })
+ );
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getAllByRole("button")[0]);
+
+ await waitFor(() => {
+ expect(setToastMock).toHaveBeenCalledWith({
+ message: "Unauthorized",
+ type: "error",
+ });
+ });
+ });
+
+ 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: " ",
+ status: 404,
+ statusText: "Not Found",
+ })
+ );
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getAllByRole("button")[0]);
+
+ await waitFor(() => {
+ expect(setToastMock).toHaveBeenCalledWith({
+ message: "Not Found",
+ type: "error",
+ });
});
});
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..49098ffc63
--- /dev/null
+++ b/__tests__/components/waves/drops/reaction-utils.test.ts
@@ -0,0 +1,281 @@
+import { getReactionErrorMessage } from "@/components/waves/drops/reaction-utils";
+
+const createStructuredReactionError = ({
+ body,
+ message = "technical error",
+ status,
+ statusText,
+}: {
+ body?: unknown;
+ message?: string;
+ status?: number;
+ statusText?: string;
+}): Error & {
+ 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 } : {}),
+ },
+ });
+
+describe("getReactionErrorMessage", () => {
+ it("surfaces the error field from structured API errors", () => {
+ expect(
+ getReactionErrorMessage(
+ createStructuredReactionError({
+ body: JSON.stringify({ error: "Rate limited" }),
+ message: "unexpected raw error",
+ status: 429,
+ }),
+ "Error adding reaction"
+ )
+ ).toBe("Rate limited");
+ });
+
+ it("surfaces the message field from structured API errors", () => {
+ expect(
+ getReactionErrorMessage(
+ createStructuredReactionError({
+ body: JSON.stringify({ message: "Unauthorized" }),
+ message: "unexpected raw error",
+ status: 401,
+ }),
+ "Error adding reaction"
+ )
+ ).toBe("Unauthorized");
+ });
+
+ it("surfaces the first details message from structured API errors", () => {
+ expect(
+ getReactionErrorMessage(
+ createStructuredReactionError({
+ body: JSON.stringify({
+ details: [{ message: "Reaction not allowed" }],
+ }),
+ message: "unexpected raw error",
+ }),
+ "Error adding reaction"
+ )
+ ).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(
+ createStructuredReactionError({
+ body: "
Bad Gateway",
+ message: "Bad Gateway",
+ status: 502,
+ }),
+ "Error adding reaction"
+ )
+ ).toBe("Error adding reaction");
+ });
+
+ it("maps unauthorized status when the structured body is missing", () => {
+ expect(
+ getReactionErrorMessage(
+ createStructuredReactionError({
+ message: "Something went wrong",
+ status: 401,
+ }),
+ "Error adding reaction"
+ )
+ ).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(
+ createStructuredReactionError({
+ body: " ",
+ message: " ",
+ status: 429,
+ statusText: "Too Many Requests",
+ }),
+ "Error adding reaction"
+ )
+ ).toBe("Too Many Requests");
+ });
+
+ 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 status text when the structured body is blank", () => {
+ expect(
+ getReactionErrorMessage(
+ createStructuredReactionError({
+ body: " ",
+ message: " ",
+ status: 404,
+ statusText: "Not Found",
+ }),
+ "Error adding reaction"
+ )
+ ).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(
+ createStructuredReactionError({
+ body: " ",
+ message: "Service Unavailable",
+ status: 503,
+ }),
+ "Error adding reaction"
+ )
+ ).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(
+ 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");
+ });
+
+ it("does not use the raw error message when an unsafe body is present", () => {
+ expect(
+ getReactionErrorMessage(
+ createStructuredReactionError({
+ body: "Bad Gateway",
+ message: "Unauthorized",
+ status: 401,
+ }),
+ "Error adding reaction"
+ )
+ ).toBe("Error adding reaction");
+ });
+});
diff --git a/__tests__/helpers/waves/create-wave.helpers.extra.test.ts b/__tests__/helpers/waves/create-wave.helpers.extra.test.ts
index 01af864ec0..3dc61d0da0 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.threshold);
+});
+
+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
new file mode 100644
index 0000000000..59da6a00fd
--- /dev/null
+++ b/__tests__/hooks/useDropReaction.test.ts
@@ -0,0 +1,267 @@
+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 * as dropReactionMonitoring from "@/utils/monitoring/dropReactionMonitoring";
+import { act, renderHook } from "@testing-library/react";
+
+const setToastMock = jest.fn();
+const rollbackMock = jest.fn();
+const applyOptimisticDropUpdateMock = jest.fn(() => ({
+ rollback: rollbackMock,
+}));
+
+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 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), {
+ ...(status !== undefined ? { status } : {}),
+ response: {
+ ...(body !== undefined ? { body } : {}),
+ ...(status !== undefined ? { status } : {}),
+ },
+ });
+
+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(
+ createStructuredReactionError({
+ body: JSON.stringify({ error: "Rate limited" }),
+ message: "unexpected raw error",
+ status: 429,
+ })
+ );
+
+ 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",
+ });
+ });
+
+ 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({
+ body: "Bad Gateway",
+ message: "Bad Gateway",
+ status: 502,
+ })
+ );
+
+ 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",
+ });
+ });
+
+ it("maps unauthorized status when the structured body is empty", async () => {
+ (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce(
+ createStructuredReactionError({
+ message: "Something went wrong",
+ status: 401,
+ })
+ );
+
+ const { result } = renderHook(() =>
+ useDropReaction(mockDrop, { source: "quick-react" })
+ );
+
+ await act(async () => {
+ await result.current.react(":smile:");
+ });
+
+ expect(setToastMock).toHaveBeenCalledWith({
+ message: "Unauthorized",
+ 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",
+ });
+ });
+
+ 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/__tests__/services/common-api.test.ts b/__tests__/services/common-api.test.ts
index 971f7d91e6..5d3664977d 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,204 @@ 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("falls back to 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("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 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/__tests__/utils/monitoring/dropReactionMonitoring.test.ts b/__tests__/utils/monitoring/dropReactionMonitoring.test.ts
new file mode 100644
index 0000000000..ff3522a96f
--- /dev/null
+++ b/__tests__/utils/monitoring/dropReactionMonitoring.test.ts
@@ -0,0 +1,407 @@
+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 after success when failure timestamp is null", () => {
+ 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);
+ expect(mutation.apiFailedAt).toBeNull();
+
+ 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("resets the per-drop sequence when the last tracked mutation ages out", () => {
+ 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,
+ });
+
+ 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-8/reaction",
+ method: "POST",
+ });
+
+ const secondMutation = beginReactionMutation({
+ dropId: "drop-8",
+ waveId: "wave-1",
+ source: "picker",
+ action: "add",
+ previousReaction: null,
+ intendedReaction: ":smile:",
+ optimisticReaction: ":smile:",
+ profileId: "profile-1",
+ websocketStatus: WebSocketStatus.CONNECTED,
+ });
+ recordReactionRequestSent(secondMutation, {
+ endpoint: "drops/drop-8/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..aeadf28588 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";
@@ -28,9 +29,19 @@ import { Tooltip } from "react-tooltip";
import {
cloneReactionEntries,
findReactionIndex,
+ getReactionErrorMessage,
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 +96,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 +338,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,34 +368,53 @@ 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) {
- let msg = selected ? "Error removing reaction" : "Error adding reaction";
- if (typeof error === "string") msg = error;
+ recordReactionRequestFailed(mutation, error);
+ const msg = getReactionErrorMessage(
+ error,
+ selected ? "Error removing reaction" : "Error adding reaction"
+ );
setToast({ message: msg, type: "error" });
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/components/waves/drops/reaction-utils.ts b/components/waves/drops/reaction-utils.ts
index 4610f0a2f1..ddfce438c6 100644
--- a/components/waves/drops/reaction-utils.ts
+++ b/components/waves/drops/reaction-utils.ts
@@ -109,3 +109,193 @@ export const toProfileMin = (
sub_classification: profile.sub_classification,
};
};
+
+type StructuredReactionError = Error & {
+ status?: unknown;
+ response?: {
+ status?: unknown;
+ statusText?: unknown;
+ body?: unknown;
+ } | null;
+};
+
+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;
+ }
+
+ for (const detail of details) {
+ if (detail === null || typeof detail !== "object") {
+ continue;
+ }
+
+ const message = (detail as { message?: unknown }).message;
+ if (typeof message !== "string") {
+ continue;
+ }
+
+ const trimmedMessage = message.trim();
+ if (trimmedMessage.length > 0) {
+ return trimmedMessage;
+ }
+ }
+
+ 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 (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"]);
+};
+
+const hasNoStructuredReactionBody = (body: unknown): boolean => {
+ if (body === null || body === undefined) {
+ return true;
+ }
+
+ 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 = (
+ 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;
+ }
+};
+
+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 => {
+ 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
+): string => {
+ if (error !== null && typeof error === "object") {
+ const structuredError = error as StructuredReactionError;
+ const structuredResponse = structuredError.response;
+ if (structuredResponse !== null && structuredResponse !== undefined) {
+ const structuredBody = structuredResponse.body;
+ const safeMessage = getStructuredReactionBodyMessage(structuredBody);
+ if (safeMessage) {
+ return safeMessage;
+ }
+
+ 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;
+};
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/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..8bf1ced170 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";
@@ -44,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,
@@ -122,27 +126,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,
}: {
@@ -150,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,
})),
@@ -166,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,
@@ -174,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,
})),
@@ -207,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,
@@ -219,7 +202,7 @@ const getApproveOutcomes = ({
} else if (
outcome.type === CreateWaveOutcomeType.REP &&
outcome.category &&
- outcome.credit
+ isPositiveFiniteNumber(outcome.credit)
) {
outcomes.push({
type: ApiWaveOutcomeType.Automatic,
@@ -229,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,
@@ -335,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 = ({
@@ -423,7 +403,10 @@ export const getCreateNewWaveBody = ({
wave: {
admin_drop_deletion_enabled: config.drops.adminCanDeleteDrops,
type: config.overview.type,
- winning_thresholds: getWinningThreshold({ config }),
+ winning_threshold:
+ config.overview.type === ApiWaveType.Approve
+ ? config.approval.threshold
+ : null,
// 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/hooks/drops/useDropReaction.ts b/hooks/drops/useDropReaction.ts
index ac93d7a880..c920afc48f 100644
--- a/hooks/drops/useDropReaction.ts
+++ b/hooks/drops/useDropReaction.ts
@@ -9,26 +9,46 @@ 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,
findReactionIndex,
+ getReactionErrorMessage,
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,47 +140,89 @@ 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);
}
+ let succeeded = false;
+
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?.();
+ succeeded = true;
} catch (error) {
- let errorMessage = isRemoving
- ? "Error removing reaction"
- : "Error adding reaction";
- if (typeof error === "string") {
- errorMessage = error;
- }
+ recordReactionRequestFailed(mutation, error);
+ const errorMessage = getReactionErrorMessage(
+ error,
+ isRemoving ? "Error removing reaction" : "Error adding reaction"
+ );
setToast({ message: errorMessage, type: "error" });
rollbackRef.current?.();
+ recordReactionRollbackApplied(mutation);
rollbackRef.current = null;
}
+
+ if (succeeded) {
+ try {
+ onSuccess?.();
+ } catch {
+ // Ignore consumer callback errors so a successful request stays successful.
+ }
+ }
},
[
canReact,
applyOptimisticReaction,
+ connectedProfile?.id,
contextProfileContext?.reaction,
drop.id,
+ dropId,
setToast,
onSuccess,
+ source,
+ waveId,
+ websocketStatus,
]
);
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
diff --git a/services/api/common-api.ts b/services/api/common-api.ts
index 51fcc54f16..25447a50c9 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;
};
};
@@ -59,15 +60,30 @@ const normalizeHeaders = (value: unknown): Headers => {
}
};
+const getUsableErrorMessage = (
+ message: string,
+ 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,
headers,
+ statusText,
body,
}: {
message: string;
status: number;
headers: Headers;
+ statusText?: string;
body?: unknown;
}): StructuredApiError => {
const error = new Error(message) as StructuredApiError;
@@ -77,6 +93,7 @@ const createStructuredApiError = ({
error.response = {
status,
headers,
+ ...(statusText !== undefined ? { statusText } : {}),
...(body !== undefined ? { body } : {}),
};
return error;
@@ -86,7 +103,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;
@@ -112,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;
}
@@ -130,16 +156,22 @@ 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,
body: errorBody,
});
}
- return Promise.reject(errorMessage);
+ return Promise.reject(normalizedErrorMessage);
};
const executeApiRequest = async (
@@ -388,6 +420,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 +430,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..197e4d08e6
--- /dev/null
+++ b/utils/monitoring/dropReactionMonitoring.ts
@@ -0,0 +1,657 @@
+"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";
+
+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);
+ dropMutationSeqByDrop.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),
+ requestSentAt: null,
+ apiSucceededAt: null,
+ apiFailedAt: null,
+ };
+
+ 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_OPTIMISTIC_REVERTED],
+ 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();
+}