diff --git a/AGENTS.md b/AGENTS.md
index ae80cf16d3..0ec7662798 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -93,6 +93,50 @@ Enable the **Next DevTools MCP server** so agents can query live routes, errors,
---
+## Sentry Error Handling
+
+When addressing issues reported by Sentry, **always attempt to fix the root cause first**. Silencing errors should only be considered as a last resort when fixing is genuinely not possible.
+
+### Default Workflow
+
+1. **Fix the code to prevent the issue** (primary action)
+ * Analyze the error stack trace and identify the root cause
+ * Implement a proper fix that addresses the underlying problem
+ * Add appropriate error handling, validation, or defensive checks
+ * Update tests to cover the fix
+
+2. **Ask about silencing only if fixing is not possible** (fallback)
+ * Only proceed to silencing if the error is genuinely unfixable, such as:
+ * Third-party library errors that cannot be patched
+ * Browser extension noise (e.g., injected scripts)
+ * Known browser bugs that cannot be worked around
+ * Expected errors that are already handled gracefully in the UI
+
+### Where Silencing Happens
+
+If silencing is necessary, errors are filtered in the `beforeSend` callback of Sentry initialization:
+
+* **Client-side**: [`instrumentation-client.ts`](instrumentation-client.ts) — contains `noisyPatterns`, `referenceErrors`, and `filenameExceptions` arrays
+* **Server-side**: [`sentry.server.config.ts`](sentry.server.config.ts) — server runtime configuration
+* **Edge runtime**: [`sentry.edge.config.ts`](sentry.edge.config.ts) — edge runtime configuration
+
+### Examples
+
+**Fixable (default action):**
+* Null reference errors → add null checks or optional chaining
+* Type errors → fix type definitions or add runtime validation
+* Network errors → implement retry logic or better error handling
+* Missing dependencies → add proper dependency arrays or fix imports
+
+**Non-fixable (ask about silencing):**
+* Browser extension injecting scripts (`inpage.js` errors)
+* Known browser bugs (e.g., `ResizeObserver loop limit exceeded` in some browsers)
+* Third-party library errors that cannot be patched without forking
+
+This aligns with the "Fix with modernization" principle: prioritize meaningful fixes over suppressing symptoms.
+
+---
+
## Next.js 16: What this means for agents
* **Proxy instead of Middleware:** `middleware.ts` is **renamed to** `proxy.ts` (Node runtime). If you touch request‑boundary logic, ensure the file and exported function are named `proxy`. Legacy `middleware.ts` still exists for edge‑only cases but our default is `proxy.ts`. ([Next.js][6])
@@ -182,6 +226,7 @@ If you add or modify `proxy.ts`, keep it at the root (or `src/`) alongside `app/
* Tests live in `__tests__/` or `ComponentName.test.tsx`.
* Mock external dependencies and APIs in tests.
* When parsing Seize URLs (or similar), **do not** fall back to placeholder origins; fail fast if base origin is unavailable.
+* **React imports:** Prefer direct named imports (`useMemo`, `useRef`, `FC`, etc.) over `React.` namespace usage (`React.useMemo`, `React.useRef`, `React.FC`, etc.). Import hooks and types directly: `import { useMemo, useRef, FC, memo } from "react"` rather than `import React from "react"` and using `React.useMemo`.
---
diff --git a/__tests__/components/notifications/NotificationsContext.test.tsx b/__tests__/components/notifications/NotificationsContext.test.tsx
index cff36f090d..3c9812b93c 100644
--- a/__tests__/components/notifications/NotificationsContext.test.tsx
+++ b/__tests__/components/notifications/NotificationsContext.test.tsx
@@ -1,9 +1,12 @@
-import React from "react";
-import { renderHook, act } from "@testing-library/react";
import {
NotificationsProvider,
useNotificationsContext,
} from "@/components/notifications/NotificationsContext";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import React from "react";
+
+const push = jest.fn();
+const mockUseRouter = jest.fn(() => ({ push }));
jest.mock("@/hooks/useCapacitor", () => () => ({
isCapacitor: true,
@@ -11,15 +14,36 @@ jest.mock("@/hooks/useCapacitor", () => () => ({
}));
jest.mock("next/navigation", () => ({
__esModule: true,
- useRouter: jest.fn(() => ({ push: jest.fn() })),
+ useRouter: () => mockUseRouter(),
}));
jest.mock("@/components/auth/Auth", () => ({
- useAuth: () => ({ connectedProfile: null }),
+ useAuth: () => ({ connectedProfile: { id: "test-profile-id" } }),
}));
jest.mock("@/services/api/common-api", () => ({
+ commonApiPost: jest.fn().mockResolvedValue({}),
commonApiPostWithoutBodyAndResponse: jest.fn().mockResolvedValue({}),
}));
+jest.mock("@capacitor/push-notifications", () => {
+ return {
+ PushNotifications: {
+ removeAllListeners: jest.fn().mockResolvedValue(undefined),
+ addListener: jest.fn(),
+ requestPermissions: jest.fn().mockResolvedValue({ receive: "granted" }),
+ register: jest.fn().mockResolvedValue(undefined),
+ getDeliveredNotifications: jest
+ .fn()
+ .mockResolvedValue({ notifications: [{ data: { wave_id: "w1" } }] }),
+ removeDeliveredNotifications: jest.fn().mockResolvedValue(undefined),
+ removeAllDeliveredNotifications: jest.fn().mockResolvedValue(undefined),
+ },
+ PushNotificationSchema: {},
+ };
+});
+jest.mock("@capacitor/device", () => ({
+ Device: { getInfo: jest.fn().mockResolvedValue({ platform: "ios" }) },
+}));
+
const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
{children}
);
@@ -44,69 +68,66 @@ describe("NotificationsContext", () => {
});
});
-jest.mock("@capacitor/push-notifications", () => ({
- PushNotifications: {
- removeAllListeners: jest.fn(),
- addListener: jest.fn(),
- requestPermissions: jest.fn().mockResolvedValue({ receive: "granted" }),
- register: jest.fn(),
- getDeliveredNotifications: jest
- .fn()
- .mockResolvedValue({ notifications: [{ data: { wave_id: "w1" } }] }),
- removeDeliveredNotifications: jest.fn(),
- removeAllDeliveredNotifications: jest.fn(),
- },
-}));
-
-jest.mock("@capacitor/device", () => ({
- Device: { getInfo: jest.fn().mockResolvedValue({ platform: "ios" }) },
-}));
-
it("removes notifications when functions called", async () => {
+ const { PushNotifications } = require("@capacitor/push-notifications");
const { result } = renderHook(() => useNotificationsContext(), { wrapper });
+
+ await waitFor(() => {
+ expect(PushNotifications.removeAllListeners).toHaveBeenCalled();
+ });
+
await act(async () => {
await result.current.removeWaveDeliveredNotifications("w1");
await result.current.removeAllDeliveredNotifications();
});
- const { PushNotifications } = require("@capacitor/push-notifications");
expect(PushNotifications.getDeliveredNotifications).toHaveBeenCalled();
expect(PushNotifications.removeDeliveredNotifications).toHaveBeenCalled();
expect(PushNotifications.removeAllDeliveredNotifications).toHaveBeenCalled();
});
describe("push notification action handling", () => {
+ beforeEach(() => {
+ push.mockClear();
+ const { PushNotifications } = require("@capacitor/push-notifications");
+ PushNotifications.addListener.mockClear();
+ PushNotifications.removeDeliveredNotifications.mockClear();
+ });
+
it("redirects based on notification data", async () => {
- const push = jest.fn();
- jest
- .spyOn(require("next/navigation"), "useRouter")
- .mockReturnValue({ push } as any);
-
- const addListenerMock = jest.fn((evt, cb) => {
- if (evt === "pushNotificationActionPerformed") {
- setTimeout(
- () =>
- cb({
- notification: {
- data: {
- redirect: "profile",
- handle: "abc",
- notification_id: "1",
- },
- },
- }),
- 0
- );
- }
+ const { PushNotifications } = require("@capacitor/push-notifications");
+ const { result } = renderHook(() => useNotificationsContext(), { wrapper });
+
+ await waitFor(() => {
+ expect(PushNotifications.addListener).toHaveBeenCalled();
});
- const { PushNotifications } = require("@capacitor/push-notifications");
- PushNotifications.addListener = addListenerMock;
+ const addListenerCalls = PushNotifications.addListener.mock.calls;
+ const actionPerformedCall = addListenerCalls.find(
+ (call: any[]) => call[0] === "pushNotificationActionPerformed"
+ );
+ const callback = actionPerformedCall?.[1];
+
+ expect(callback).toBeDefined();
- const { result } = renderHook(() => useNotificationsContext(), { wrapper });
await act(async () => {
+ if (callback) {
+ await callback({
+ notification: {
+ data: {
+ redirect: "profile",
+ handle: "abc",
+ notification_id: "1",
+ },
+ },
+ });
+ }
await new Promise((r) => setTimeout(r, 100));
});
- expect(push).toHaveBeenCalledWith("/abc");
+
+ await waitFor(() => {
+ expect(push).toHaveBeenCalledWith("/abc");
+ });
+
expect(PushNotifications.removeDeliveredNotifications).toHaveBeenCalled();
});
});
diff --git a/__tests__/components/waves/CreateDropStormParts.test.tsx b/__tests__/components/waves/CreateDropStormParts.test.tsx
index 661319c736..7510508104 100644
--- a/__tests__/components/waves/CreateDropStormParts.test.tsx
+++ b/__tests__/components/waves/CreateDropStormParts.test.tsx
@@ -1,20 +1,26 @@
-import { render, screen } from '@testing-library/react';
-import React from 'react';
-import CreateDropStormParts from '@/components/waves/CreateDropStormParts';
-import { AuthContext } from '@/components/auth/Auth';
+import { AuthContext } from "@/components/auth/Auth";
+import CreateDropStormParts from "@/components/waves/CreateDropStormParts";
+import { render, screen } from "@testing-library/react";
-jest.mock('@/components/waves/CreateDropStormPart', () => ({
+jest.mock("@/components/waves/CreateDropStormPart", () => ({
__esModule: true,
default: ({ partIndex }: any) =>
,
}));
+jest.mock("framer-motion", () => ({
+ AnimatePresence: ({ children }: any) => children,
+ motion: {
+ div: ({ children, ...props }: any) => {children}
,
+ },
+}));
+
const authValue = {
- connectedProfile: { handle: 'user', pfp: 'img.png', level: 1, cic: 0 },
+ connectedProfile: { handle: "user", pfp: "img.png", level: 1, cic: 0 },
} as any;
-describe('CreateDropStormParts', () => {
- it('renders parts with profile info', () => {
- const parts = [{ content: 'a' }, { content: 'b' }] as any;
+describe("CreateDropStormParts", () => {
+ it("renders parts with profile info", () => {
+ const parts = [{ content: "a" }, { content: "b" }] as any;
render(
{
/>
);
- expect(screen.getByTestId('part-0')).toBeInTheDocument();
- expect(screen.getByTestId('part-1')).toBeInTheDocument();
- expect(screen.getByRole('img')).toHaveAttribute('src', 'img.png');
- expect(screen.getByRole('link')).toHaveAttribute('href', '/user');
+ expect(screen.getByTestId("part-0")).toBeInTheDocument();
+ expect(screen.getByTestId("part-1")).toBeInTheDocument();
+ expect(screen.getByRole("img")).toHaveAttribute("src", "img.png");
+ expect(screen.getByRole("link")).toHaveAttribute("href", "/user");
});
});
diff --git a/components/waves/CreateDropContent.tsx b/components/waves/CreateDropContent.tsx
index 1cc23a41ef..25528fbcd9 100644
--- a/components/waves/CreateDropContent.tsx
+++ b/components/waves/CreateDropContent.tsx
@@ -609,23 +609,27 @@ const CreateDropContent: React.FC = ({
allMentions: ApiDropMentionedUser[],
allNfts: ReferencedNft[]
): CreateDropConfig => {
- const parts = ensurePartsWithFallback(
- [
- ...(drop?.parts ?? []),
- {
- content: markdown?.length ? markdown : null,
- quoted_drop:
- activeDrop?.action === ActiveDropAction.QUOTE
- ? {
- drop_id: activeDrop.drop.id,
- drop_part_id: activeDrop.partId,
- }
- : null,
- media: files,
- },
- ],
- hasMetadata
- );
+ const hasPartsInDrop = (drop?.parts.length ?? 0) > 0;
+ const hasCurrentContent = !!(markdown?.trim().length || files.length);
+
+ const newParts = hasPartsInDrop && !hasCurrentContent
+ ? drop?.parts ?? []
+ : [
+ ...(drop?.parts ?? []),
+ {
+ content: markdown?.length ? markdown : null,
+ quoted_drop:
+ activeDrop?.action === ActiveDropAction.QUOTE
+ ? {
+ drop_id: activeDrop.drop.id,
+ drop_part_id: activeDrop.partId,
+ }
+ : null,
+ media: files,
+ },
+ ];
+
+ const parts = ensurePartsWithFallback(newParts, hasMetadata);
return {
title: null,
@@ -869,6 +873,15 @@ const CreateDropContent: React.FC = ({
) {
return;
}
+
+ const hasPartsInDrop = (drop?.parts.length ?? 0) > 0;
+ const hasCurrentContent = !!(getMarkdown?.trim().length || files.length);
+
+ if (hasPartsInDrop && hasCurrentContent) {
+ finalizeAndAddDropPart();
+ return;
+ }
+
await prepareAndSubmitDrop(getUpdatedDrop());
};
diff --git a/components/waves/CreateDropStormParts.tsx b/components/waves/CreateDropStormParts.tsx
index 5f8c439fdf..f697fe8d99 100644
--- a/components/waves/CreateDropStormParts.tsx
+++ b/components/waves/CreateDropStormParts.tsx
@@ -1,16 +1,24 @@
"use client";
-import React from "react";
import { CreateDropPart, ReferencedNft } from "@/entities/IDrop";
import { ApiDropMentionedUser } from "@/generated/models/ApiDropMentionedUser";
-import { AuthContext } from "../auth/Auth";
import { cicToType } from "@/helpers/Helpers";
-import Link from "next/link";
-import CreateDropStormPart from "./CreateDropStormPart";
import { AnimatePresence, motion } from "framer-motion";
+import Link from "next/link";
+import {
+ FC,
+ memo,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { AuthContext } from "../auth/Auth";
import UserCICAndLevel, {
UserCICAndLevelSize,
} from "../user/utils/UserCICAndLevel";
+import CreateDropStormPart from "./CreateDropStormPart";
interface CreateDropStormPartsProps {
parts: CreateDropPart[];
@@ -19,15 +27,53 @@ interface CreateDropStormPartsProps {
onRemovePart: (partIndex: number) => void;
}
-const CreateDropStormParts: React.FC = ({
+const CreateDropStormParts: FC = ({
parts,
mentionedUsers,
referencedNfts,
onRemovePart,
}) => {
- const { connectedProfile } = React.useContext(AuthContext);
+ const { connectedProfile } = useContext(AuthContext);
const cicType = cicToType(connectedProfile?.cic ?? 0);
+ const partIdCounterRef = useRef(0);
+ const [partIdsMap, setPartIdsMap] = useState