diff --git a/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx b/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx
index a2921ac14a..27a442e98b 100644
--- a/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx
+++ b/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx
@@ -40,6 +40,10 @@ describe('BrainLeftSidebarWave', () => {
contributors: [],
newDropsCount: { count: 2, latestDropTimestamp: 123 },
isPinned: false,
+ unreadDropsCount: 0,
+ latestReadTimestamp: 0,
+ firstUnreadDropSerialNo: null,
+ isMuted: false,
} as any;
beforeEach(() => {
@@ -85,7 +89,7 @@ describe('BrainLeftSidebarWave', () => {
render();
const link = screen.getByRole('link');
await userEvent.click(link);
- expect(setActiveWave).toHaveBeenCalledWith('1', { isDirectMessage: false });
+ expect(setActiveWave).toHaveBeenCalledWith('1', { isDirectMessage: false, serialNo: null });
});
it('shows drop indicators for non-chat waves', () => {
@@ -93,4 +97,30 @@ describe('BrainLeftSidebarWave', () => {
render();
expect(screen.getByTestId('drop-time')).toHaveTextContent('123');
});
+
+ it('includes firstUnreadDropSerialNo in href when present', () => {
+ const waveWithUnread = { ...baseWave, id: '3', firstUnreadDropSerialNo: 42 };
+ render();
+ expect(screen.getByRole('link')).toHaveAttribute('href', '/waves?wave=3&serialNo=42');
+ });
+
+ it('does not include serialNo in href when firstUnreadDropSerialNo is null', () => {
+ const waveWithoutUnread = { ...baseWave, id: '4', firstUnreadDropSerialNo: null };
+ render();
+ expect(screen.getByRole('link')).toHaveAttribute('href', '/waves?wave=4');
+ });
+
+ it('shows muted indicator when wave is muted', () => {
+ const mutedWave = { ...baseWave, id: '5', isMuted: true };
+ render();
+ const bellSlashIcons = document.querySelectorAll('[data-icon="bell-slash"]');
+ expect(bellSlashIcons.length).toBeGreaterThan(0);
+ });
+
+ it('does not show muted indicator when wave is not muted', () => {
+ const unmutedWave = { ...baseWave, id: '6', isMuted: false };
+ render();
+ const bellSlashIcons = document.querySelectorAll('[data-icon="bell-slash"]');
+ expect(bellSlashIcons.length).toBe(0);
+ });
});
diff --git a/__tests__/components/drops/view/DropsList.test.tsx b/__tests__/components/drops/view/DropsList.test.tsx
index 1517326aa3..8dad3b5e70 100644
--- a/__tests__/components/drops/view/DropsList.test.tsx
+++ b/__tests__/components/drops/view/DropsList.test.tsx
@@ -46,6 +46,11 @@ jest.mock("@/components/drops/view/HighlightDropWrapper", () => ({
},
}));
+jest.mock("@/components/drops/view/UnreadDivider", () => ({
+ __esModule: true,
+ default: () =>
() =>
);
jest.mock('@/components/waves/drops/WaveDropFollowAuthor', () => () =>
);
jest.mock('@/components/waves/drops/WaveDropActionsAddReaction', () => () =>
);
+jest.mock('@/components/waves/drops/WaveDropActionsMarkUnread', () => () =>
);
jest.mock('@/hooks/drops/useDropInteractionRules', () => ({ useDropInteractionRules: jest.fn() }));
jest.mock('@/contexts/SeizeSettingsContext', () => ({ useSeizeSettings: jest.fn() }));
diff --git a/__tests__/components/waves/drops/WaveDropActionsMarkUnread.test.tsx b/__tests__/components/waves/drops/WaveDropActionsMarkUnread.test.tsx
new file mode 100644
index 0000000000..bdcd9a6315
--- /dev/null
+++ b/__tests__/components/waves/drops/WaveDropActionsMarkUnread.test.tsx
@@ -0,0 +1,157 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import WaveDropActionsMarkUnread from '@/components/waves/drops/WaveDropActionsMarkUnread';
+import { AuthContext } from '@/components/auth/Auth';
+import { ApiDrop } from '@/generated/models/ApiDrop';
+
+jest.mock('@/services/api/common-api', () => ({
+ commonApiPost: jest.fn(),
+}));
+
+jest.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: jest.fn(),
+ }),
+}));
+
+jest.mock('@/contexts/wave/UnreadDividerContext', () => ({
+ useUnreadDividerOptional: () => ({
+ setUnreadDividerSerialNo: jest.fn(),
+ }),
+}));
+
+jest.mock('@/contexts/wave/MyStreamContext', () => ({
+ useMyStream: () => ({
+ waves: {
+ restoreWaveUnreadCount: jest.fn(),
+ },
+ directMessages: {
+ restoreWaveUnreadCount: jest.fn(),
+ },
+ }),
+}));
+
+jest.mock('react-tooltip', () => ({
+ Tooltip: ({ children }: any) =>
{children}
,
+}));
+
+const mockAuthContext = {
+ setToast: jest.fn(),
+ connectedProfile: {
+ handle: 'test-user',
+ },
+ activeProfileProxy: null,
+};
+
+const mockDrop: ApiDrop = {
+ id: 'drop-123',
+ serial_no: 42,
+ wave: {
+ id: 'wave-456',
+ name: 'Test Wave',
+ },
+ author: {
+ handle: 'other-author',
+ },
+} as any;
+
+describe('WaveDropActionsMarkUnread', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const renderComponent = () => {
+ return render(
+
+
+
+ );
+ };
+
+ it('renders mark unread button', () => {
+ renderComponent();
+ expect(screen.getByLabelText('Mark as unread')).toBeInTheDocument();
+ });
+
+ it('calls API when clicked', async () => {
+ const { commonApiPost } = require('@/services/api/common-api');
+ commonApiPost.mockResolvedValue({
+ your_unread_drops_count: 5,
+ first_unread_drop_serial_no: 42,
+ });
+
+ renderComponent();
+
+ await userEvent.click(screen.getByLabelText('Mark as unread'));
+
+ await waitFor(() => {
+ expect(commonApiPost).toHaveBeenCalledWith({
+ endpoint: 'drops/drop-123/mark-unread',
+ body: {},
+ });
+ });
+ });
+
+ it('shows success toast on success', async () => {
+ const { commonApiPost } = require('@/services/api/common-api');
+ commonApiPost.mockResolvedValue({
+ your_unread_drops_count: 5,
+ first_unread_drop_serial_no: 42,
+ });
+
+ renderComponent();
+
+ await userEvent.click(screen.getByLabelText('Mark as unread'));
+
+ await waitFor(() => {
+ expect(mockAuthContext.setToast).toHaveBeenCalledWith({
+ message: 'Marked as unread',
+ type: 'success',
+ });
+ });
+ });
+
+ it('shows error toast on failure', async () => {
+ const { commonApiPost } = require('@/services/api/common-api');
+ commonApiPost.mockRejectedValue('API Error');
+
+ renderComponent();
+
+ await userEvent.click(screen.getByLabelText('Mark as unread'));
+
+ await waitFor(() => {
+ expect(mockAuthContext.setToast).toHaveBeenCalledWith({
+ message: 'API Error',
+ type: 'error',
+ });
+ });
+ });
+
+ it('shows loading spinner while marking unread', async () => {
+ const { commonApiPost } = require('@/services/api/common-api');
+ commonApiPost.mockImplementation(() => new Promise(() => {}));
+
+ renderComponent();
+
+ await userEvent.click(screen.getByLabelText('Mark as unread'));
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('Mark as unread').querySelector('.spinner')).toBeInTheDocument();
+ });
+ });
+
+ it('disables button while loading', async () => {
+ const { commonApiPost } = require('@/services/api/common-api');
+ commonApiPost.mockImplementation(() => new Promise(() => {}));
+
+ renderComponent();
+
+ const button = screen.getByLabelText('Mark as unread');
+ await userEvent.click(button);
+
+ await waitFor(() => {
+ expect(button).toBeDisabled();
+ });
+ });
+});
+
diff --git a/__tests__/components/waves/drops/WaveDropsAll.test.tsx b/__tests__/components/waves/drops/WaveDropsAll.test.tsx
index 309b9fbdaa..9127cd6246 100644
--- a/__tests__/components/waves/drops/WaveDropsAll.test.tsx
+++ b/__tests__/components/waves/drops/WaveDropsAll.test.tsx
@@ -434,7 +434,7 @@ describe('WaveDropsAll', () => {
describe('Navigation and Quote Handling', () => {
it('navigates to different wave when quote is from another wave', () => {
const mockDrop = createMockDrop({
- wave: { id: 'other-wave', name: 'Other Wave', picture: null, description_drop_id: null },
+ wave: { id: 'other-wave', name: 'Other Wave', picture: null, description_drop_id: '' },
serial_no: 42
}) as any;
@@ -453,7 +453,7 @@ describe('WaveDropsAll', () => {
it('sets serial number for same wave quote navigation', () => {
const mockDrop = createMockDrop({
- wave: { id: 'current-wave', name: 'Current Wave', picture: null, description_drop_id: null },
+ wave: { id: 'current-wave', name: 'Current Wave', picture: null, description_drop_id: '' },
serial_no: 42
}) as any;
diff --git a/__tests__/components/waves/header/WaveHeaderOptions.test.tsx b/__tests__/components/waves/header/WaveHeaderOptions.test.tsx
index 72f4d76145..aac92cceae 100644
--- a/__tests__/components/waves/header/WaveHeaderOptions.test.tsx
+++ b/__tests__/components/waves/header/WaveHeaderOptions.test.tsx
@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import WaveHeaderOptions from '@/components/waves/header/options/WaveHeaderOptions';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
let clickAway: () => void; let escCb: () => void;
@@ -16,19 +17,29 @@ jest.mock('framer-motion', () => ({
jest.mock('@/components/waves/header/options/delete/WaveDelete', () => (props: any) =>
);
-const wave = { id: 'w1' } as any;
+jest.mock('@/components/waves/header/options/mute/WaveMute', () => (props: any) =>
);
+
+const wave = { id: 'w1', metrics: { muted: false } } as any;
+
+const createWrapper = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return ({ children }: { children: React.ReactNode }) => (
+
{children}
+ );
+};
test('opens and closes options', async () => {
const user = userEvent.setup();
- const { rerender } = render(
);
+ const { rerender } = render(
, { wrapper: createWrapper() });
const btn = screen.getByRole('button');
await user.click(btn);
expect(screen.getByTestId('delete')).toHaveAttribute('data-wave','w1');
- // click away
+ expect(screen.getByTestId('mute')).toHaveAttribute('data-wave','w1');
clickAway();
rerender(
);
expect(screen.queryByTestId('delete')).toBeNull();
- // open again and press escape
await user.click(btn);
escCb();
rerender(
);
diff --git a/__tests__/components/waves/header/options/mute/WaveMute.test.tsx b/__tests__/components/waves/header/options/mute/WaveMute.test.tsx
new file mode 100644
index 0000000000..f9213e4c2d
--- /dev/null
+++ b/__tests__/components/waves/header/options/mute/WaveMute.test.tsx
@@ -0,0 +1,173 @@
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import WaveMute from '@/components/waves/header/options/mute/WaveMute';
+import { AuthContext } from '@/components/auth/Auth';
+import { ApiWave } from '@/generated/models/ApiWave';
+
+jest.mock('@/services/api/common-api', () => ({
+ commonApiPost: jest.fn(),
+ commonApiDelete: jest.fn(),
+}));
+
+jest.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: jest.fn(),
+ }),
+}));
+
+const mockAuthContext = {
+ setToast: jest.fn(),
+};
+
+const mockWaveNotMuted: ApiWave = {
+ id: 'wave-123',
+ name: 'Test Wave',
+ metrics: {
+ muted: false,
+ },
+} as any;
+
+const mockWaveMuted: ApiWave = {
+ id: 'wave-456',
+ name: 'Muted Wave',
+ metrics: {
+ muted: true,
+ },
+} as any;
+
+describe('WaveMute', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const renderComponent = (wave: ApiWave, onSuccess?: () => void) => {
+ return render(
+
+
+
+ );
+ };
+
+ it('renders Mute button when wave is not muted', () => {
+ renderComponent(mockWaveNotMuted);
+
+ expect(screen.getByRole('menuitem')).toHaveTextContent('Mute');
+ });
+
+ it('renders Unmute button when wave is muted', () => {
+ renderComponent(mockWaveMuted);
+
+ expect(screen.getByRole('menuitem')).toHaveTextContent('Unmute');
+ });
+
+ it('calls mute API when clicking Mute', async () => {
+ const { commonApiPost } = require('@/services/api/common-api');
+ const onSuccess = jest.fn();
+ commonApiPost.mockResolvedValue({});
+
+ renderComponent(mockWaveNotMuted, onSuccess);
+
+ await userEvent.click(screen.getByRole('menuitem'));
+
+ await waitFor(() => {
+ expect(commonApiPost).toHaveBeenCalledWith({
+ endpoint: 'waves/wave-123/mute',
+ body: {},
+ });
+ });
+ expect(onSuccess).toHaveBeenCalled();
+ });
+
+ it('calls unmute API when clicking Unmute', async () => {
+ const { commonApiDelete } = require('@/services/api/common-api');
+ const onSuccess = jest.fn();
+ commonApiDelete.mockResolvedValue({});
+
+ renderComponent(mockWaveMuted, onSuccess);
+
+ await userEvent.click(screen.getByRole('menuitem'));
+
+ await waitFor(() => {
+ expect(commonApiDelete).toHaveBeenCalledWith({
+ endpoint: 'waves/wave-456/mute',
+ });
+ });
+ expect(onSuccess).toHaveBeenCalled();
+ });
+
+ it('shows Muting with spinner while muting', async () => {
+ const { commonApiPost } = require('@/services/api/common-api');
+ commonApiPost.mockImplementation(() => new Promise(() => {}));
+
+ renderComponent(mockWaveNotMuted);
+
+ await userEvent.click(screen.getByRole('menuitem'));
+
+ await waitFor(() => {
+ expect(screen.getByRole('menuitem')).toHaveTextContent('Muting');
+ expect(screen.getByRole('menuitem').querySelector('.spinner')).toBeInTheDocument();
+ });
+ });
+
+ it('shows Unmuting with spinner while unmuting', async () => {
+ const { commonApiDelete } = require('@/services/api/common-api');
+ commonApiDelete.mockImplementation(() => new Promise(() => {}));
+
+ renderComponent(mockWaveMuted);
+
+ await userEvent.click(screen.getByRole('menuitem'));
+
+ await waitFor(() => {
+ expect(screen.getByRole('menuitem')).toHaveTextContent('Unmuting');
+ expect(screen.getByRole('menuitem').querySelector('.spinner')).toBeInTheDocument();
+ });
+ });
+
+ it('handles error when muting fails', async () => {
+ const { commonApiPost } = require('@/services/api/common-api');
+ commonApiPost.mockRejectedValue('Unable to mute wave');
+
+ renderComponent(mockWaveNotMuted);
+
+ await userEvent.click(screen.getByRole('menuitem'));
+
+ await waitFor(() => {
+ expect(mockAuthContext.setToast).toHaveBeenCalledWith({
+ message: 'Unable to mute wave',
+ type: 'error',
+ });
+ });
+ });
+
+ it('handles error when unmuting fails', async () => {
+ const { commonApiDelete } = require('@/services/api/common-api');
+ commonApiDelete.mockRejectedValue('Unable to unmute wave');
+
+ renderComponent(mockWaveMuted);
+
+ await userEvent.click(screen.getByRole('menuitem'));
+
+ await waitFor(() => {
+ expect(mockAuthContext.setToast).toHaveBeenCalledWith({
+ message: 'Unable to unmute wave',
+ type: 'error',
+ });
+ });
+ });
+
+ it('disables button while loading', async () => {
+ const { commonApiPost } = require('@/services/api/common-api');
+ commonApiPost.mockImplementation(() => new Promise(() => {}));
+
+ renderComponent(mockWaveNotMuted);
+
+ const menuitem = screen.getByRole('menuitem');
+ await userEvent.click(menuitem);
+
+ await waitFor(() => {
+ expect(menuitem).toBeDisabled();
+ });
+ });
+});
+
diff --git a/__tests__/components/waves/specs/WaveNotificationSettings.test.tsx b/__tests__/components/waves/specs/WaveNotificationSettings.test.tsx
index 6937e1fb4f..fd063b9adc 100644
--- a/__tests__/components/waves/specs/WaveNotificationSettings.test.tsx
+++ b/__tests__/components/waves/specs/WaveNotificationSettings.test.tsx
@@ -14,6 +14,12 @@ jest.mock('@/services/api/common-api', () => ({
commonApiDelete: jest.fn(),
}));
+jest.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: jest.fn(),
+ }),
+}));
+
jest.mock('@/contexts/SeizeSettingsContext', () => ({
useSeizeSettings: () => ({
seizeSettings: {
@@ -32,6 +38,7 @@ const mockWave: ApiWave = {
name: 'Test Wave',
metrics: {
subscribers_count: 50,
+ muted: false,
},
subscribed_actions: ['follow'],
} as any;
@@ -41,6 +48,17 @@ const mockWaveHighSubscribers: ApiWave = {
name: 'Popular Wave',
metrics: {
subscribers_count: 1500,
+ muted: false,
+ },
+ subscribed_actions: ['follow'],
+} as any;
+
+const mockWaveMuted: ApiWave = {
+ id: 'wave-muted',
+ name: 'Muted Wave',
+ metrics: {
+ subscribers_count: 50,
+ muted: true,
},
subscribed_actions: ['follow'],
} as any;
@@ -258,4 +276,66 @@ describe('WaveNotificationSettings', () => {
const allButton = screen.getByLabelText('Receive all notifications');
expect(allButton).toBeDisabled();
});
+
+ it('renders muted button when wave is muted', () => {
+ renderComponent(mockWaveMuted);
+
+ const mutedButton = screen.getByLabelText('Unmute wave');
+ expect(mutedButton).toBeInTheDocument();
+ expect(screen.getByText('Muted')).toBeInTheDocument();
+ });
+
+ it('does not render notification settings when wave is muted', () => {
+ renderComponent(mockWaveMuted);
+
+ expect(screen.queryByLabelText('Receive mentions-only notifications')).not.toBeInTheDocument();
+ expect(screen.queryByLabelText('Receive all notifications')).not.toBeInTheDocument();
+ });
+
+ it('calls unmute API when clicking muted button', async () => {
+ const { commonApiDelete } = require('@/services/api/common-api');
+ commonApiDelete.mockResolvedValue({});
+
+ renderComponent(mockWaveMuted);
+
+ const mutedButton = screen.getByLabelText('Unmute wave');
+ await userEvent.click(mutedButton);
+
+ await waitFor(() => {
+ expect(commonApiDelete).toHaveBeenCalledWith({
+ endpoint: 'waves/wave-muted/mute',
+ });
+ });
+ });
+
+ it('shows loading spinner when unmuting', async () => {
+ const { commonApiDelete } = require('@/services/api/common-api');
+ commonApiDelete.mockImplementation(() => new Promise(() => {}));
+
+ renderComponent(mockWaveMuted);
+
+ const mutedButton = screen.getByLabelText('Unmute wave');
+ await userEvent.click(mutedButton);
+
+ await waitFor(() => {
+ expect(mutedButton.querySelector('.spinner')).toBeInTheDocument();
+ });
+ });
+
+ it('handles error when unmuting fails', async () => {
+ const { commonApiDelete } = require('@/services/api/common-api');
+ commonApiDelete.mockRejectedValue('Unable to unmute wave');
+
+ renderComponent(mockWaveMuted);
+
+ const mutedButton = screen.getByLabelText('Unmute wave');
+ await userEvent.click(mutedButton);
+
+ await waitFor(() => {
+ expect(mockAuthContext.setToast).toHaveBeenCalledWith({
+ message: 'Unable to unmute wave',
+ type: 'error',
+ });
+ });
+ });
});
\ No newline at end of file
diff --git a/__tests__/contexts/wave/UnreadDividerContext.test.tsx b/__tests__/contexts/wave/UnreadDividerContext.test.tsx
new file mode 100644
index 0000000000..f6063da1df
--- /dev/null
+++ b/__tests__/contexts/wave/UnreadDividerContext.test.tsx
@@ -0,0 +1,92 @@
+import {
+ UnreadDividerProvider,
+ useUnreadDivider,
+ useUnreadDividerOptional,
+} from "@/contexts/wave/UnreadDividerContext";
+import { act, renderHook } from "@testing-library/react";
+import { ReactNode } from "react";
+
+function createWrapper(initialSerialNo: number | null) {
+ return function Wrapper({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+ };
+}
+
+function renderUnreadDividerHook() {
+ return renderHook(useUnreadDivider);
+}
+
+describe("UnreadDividerContext", () => {
+ describe("useUnreadDivider", () => {
+ it("throws when used outside provider", () => {
+ const consoleError = jest
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+
+ expect(renderUnreadDividerHook).toThrow(
+ "useUnreadDivider must be used within an UnreadDividerProvider"
+ );
+
+ consoleError.mockRestore();
+ });
+
+ it("returns initial serial number", () => {
+ const { result } = renderHook(() => useUnreadDivider(), {
+ wrapper: createWrapper(42),
+ });
+
+ expect(result.current.unreadDividerSerialNo).toBe(42);
+ });
+
+ it("returns null when initialSerialNo is null", () => {
+ const { result } = renderHook(() => useUnreadDivider(), {
+ wrapper: createWrapper(null),
+ });
+
+ expect(result.current.unreadDividerSerialNo).toBeNull();
+ });
+
+ it("updates serial number when setUnreadDividerSerialNo is called", () => {
+ const { result } = renderHook(() => useUnreadDivider(), {
+ wrapper: createWrapper(null),
+ });
+
+ act(() => {
+ result.current.setUnreadDividerSerialNo(99);
+ });
+
+ expect(result.current.unreadDividerSerialNo).toBe(99);
+ });
+
+ it("clears serial number when set to null", () => {
+ const { result } = renderHook(() => useUnreadDivider(), {
+ wrapper: createWrapper(42),
+ });
+
+ act(() => {
+ result.current.setUnreadDividerSerialNo(null);
+ });
+
+ expect(result.current.unreadDividerSerialNo).toBeNull();
+ });
+ });
+
+ describe("useUnreadDividerOptional", () => {
+ it("returns null when used outside provider", () => {
+ const { result } = renderHook(() => useUnreadDividerOptional());
+ expect(result.current).toBeNull();
+ });
+
+ it("returns context when used inside provider", () => {
+ const { result } = renderHook(() => useUnreadDividerOptional(), {
+ wrapper: createWrapper(42),
+ });
+
+ expect(result.current?.unreadDividerSerialNo).toBe(42);
+ });
+ });
+});
diff --git a/__tests__/contexts/wave/hooks/useActiveWaveManager.test.tsx b/__tests__/contexts/wave/hooks/useActiveWaveManager.test.tsx
index 655e203a2f..f64666857c 100644
--- a/__tests__/contexts/wave/hooks/useActiveWaveManager.test.tsx
+++ b/__tests__/contexts/wave/hooks/useActiveWaveManager.test.tsx
@@ -57,4 +57,40 @@ describe("useActiveWaveManager", () => {
});
expect(pushStateSpy).toHaveBeenLastCalledWith(null, "", "/waves");
});
+
+ it("includes serialNo when provided via options", async () => {
+ globalThis.history.replaceState(null, "", "http://localhost/waves");
+ (useSearchParams as jest.Mock).mockReturnValue(new URLSearchParams({}));
+
+ const pushStateSpy = jest.spyOn(globalThis.history, "pushState");
+
+ const { result } = renderHook(() => useActiveWaveManager());
+
+ act(() => {
+ result.current.setActiveWave("wave-123", { serialNo: 42 });
+ });
+ expect(pushStateSpy).toHaveBeenLastCalledWith(
+ null,
+ "",
+ "/waves?wave=wave-123&serialNo=42"
+ );
+ });
+
+ it("includes serialNo as string in URL", async () => {
+ globalThis.history.replaceState(null, "", "http://localhost/waves");
+ (useSearchParams as jest.Mock).mockReturnValue(new URLSearchParams({}));
+
+ const pushStateSpy = jest.spyOn(globalThis.history, "pushState");
+
+ const { result } = renderHook(() => useActiveWaveManager());
+
+ act(() => {
+ result.current.setActiveWave("wave-xyz", { serialNo: "99" });
+ });
+ expect(pushStateSpy).toHaveBeenLastCalledWith(
+ null,
+ "",
+ "/waves?wave=wave-xyz&serialNo=99"
+ );
+ });
});
diff --git a/__tests__/contexts/wave/hooks/useEnhancedWavesList.test.tsx b/__tests__/contexts/wave/hooks/useEnhancedWavesList.test.tsx
index 832ca8e47b..b8d129429a 100644
--- a/__tests__/contexts/wave/hooks/useEnhancedWavesList.test.tsx
+++ b/__tests__/contexts/wave/hooks/useEnhancedWavesList.test.tsx
@@ -24,7 +24,13 @@ describe('useEnhancedWavesList', () => {
name: 'A',
picture: 'p',
contributors_overview: [{ contributor_pfp: '1.png' }],
- metrics: { latest_drop_timestamp: 100 },
+ metrics: {
+ latest_drop_timestamp: 100,
+ your_unread_drops_count: 0,
+ your_latest_read_timestamp: 0,
+ first_unread_drop_serial_no: null,
+ muted: false,
+ },
wave: { type: ApiWaveType.Chat },
};
const waveB: any = {
@@ -32,9 +38,15 @@ describe('useEnhancedWavesList', () => {
name: 'B',
picture: null,
contributors_overview: [{ contributor_pfp: '2.png' }],
- metrics: { latest_drop_timestamp: 200 },
+ metrics: {
+ latest_drop_timestamp: 200,
+ your_unread_drops_count: 5,
+ your_latest_read_timestamp: 150,
+ first_unread_drop_serial_no: 42,
+ muted: false,
+ },
wave: { type: ApiWaveType.Rank },
- isPinned: true,
+ pinned: true,
};
const fetchNextPage = jest.fn();
const addPinnedWave = jest.fn();
@@ -65,4 +77,116 @@ describe('useEnhancedWavesList', () => {
expect(result.current.waves[0].newDropsCount.latestDropTimestamp).toBe(300);
expect(result.current.waves[0].isPinned).toBe(true);
});
+
+ it('maps unreadDropsCount and firstUnreadDropSerialNo from wave metrics', () => {
+ const waveWithUnread: any = {
+ id: 'unread-wave',
+ name: 'Unread Wave',
+ picture: null,
+ contributors_overview: [],
+ metrics: {
+ latest_drop_timestamp: 100,
+ your_unread_drops_count: 15,
+ your_latest_read_timestamp: 90,
+ first_unread_drop_serial_no: 123,
+ muted: false,
+ },
+ wave: { type: ApiWaveType.Chat },
+ };
+
+ wavesListMock.mockReturnValue({
+ waves: [waveWithUnread],
+ isFetching: false,
+ isFetchingNextPage: false,
+ hasNextPage: false,
+ fetchNextPage: jest.fn(),
+ addPinnedWave: jest.fn(),
+ removePinnedWave: jest.fn(),
+ refetchAllWaves: jest.fn(),
+ mainWavesRefetch: jest.fn(),
+ pinnedWaves: [],
+ } as any);
+ newDropCounterMock.mockReturnValue({
+ newDropsCounts: {},
+ resetAllWavesNewDropsCount: jest.fn(),
+ } as any);
+
+ const { result } = renderHook(() => useEnhancedWavesList(null));
+ expect(result.current.waves[0].unreadDropsCount).toBe(15);
+ expect(result.current.waves[0].latestReadTimestamp).toBe(90);
+ expect(result.current.waves[0].firstUnreadDropSerialNo).toBe(123);
+ });
+
+ it('maps isMuted from wave metrics', () => {
+ const mutedWave: any = {
+ id: 'muted-wave',
+ name: 'Muted Wave',
+ picture: null,
+ contributors_overview: [],
+ metrics: {
+ latest_drop_timestamp: 100,
+ your_unread_drops_count: 0,
+ your_latest_read_timestamp: 0,
+ first_unread_drop_serial_no: null,
+ muted: true,
+ },
+ wave: { type: ApiWaveType.Chat },
+ };
+
+ wavesListMock.mockReturnValue({
+ waves: [mutedWave],
+ isFetching: false,
+ isFetchingNextPage: false,
+ hasNextPage: false,
+ fetchNextPage: jest.fn(),
+ addPinnedWave: jest.fn(),
+ removePinnedWave: jest.fn(),
+ refetchAllWaves: jest.fn(),
+ mainWavesRefetch: jest.fn(),
+ pinnedWaves: [],
+ } as any);
+ newDropCounterMock.mockReturnValue({
+ newDropsCounts: {},
+ resetAllWavesNewDropsCount: jest.fn(),
+ } as any);
+
+ const { result } = renderHook(() => useEnhancedWavesList(null));
+ expect(result.current.waves[0].isMuted).toBe(true);
+ });
+
+ it('sets firstUnreadDropSerialNo to null when first_unread_drop_serial_no is undefined', () => {
+ const waveNoUnreadSerial: any = {
+ id: 'no-serial-wave',
+ name: 'No Serial Wave',
+ picture: null,
+ contributors_overview: [],
+ metrics: {
+ latest_drop_timestamp: 100,
+ your_unread_drops_count: 0,
+ your_latest_read_timestamp: 0,
+ muted: false,
+ },
+ wave: { type: ApiWaveType.Chat },
+ };
+
+ wavesListMock.mockReturnValue({
+ waves: [waveNoUnreadSerial],
+ isFetching: false,
+ isFetchingNextPage: false,
+ hasNextPage: false,
+ fetchNextPage: jest.fn(),
+ addPinnedWave: jest.fn(),
+ removePinnedWave: jest.fn(),
+ refetchAllWaves: jest.fn(),
+ mainWavesRefetch: jest.fn(),
+ pinnedWaves: [],
+ } as any);
+ newDropCounterMock.mockReturnValue({
+ newDropsCounts: {},
+ resetAllWavesNewDropsCount: jest.fn(),
+ } as any);
+
+ const { result } = renderHook(() => useEnhancedWavesList(null));
+ expect(result.current.waves[0].firstUnreadDropSerialNo).toBeNull();
+ });
});
diff --git a/__tests__/helpers/navigation.helpers.test.ts b/__tests__/helpers/navigation.helpers.test.ts
index a9fc403803..481a9f7ed2 100644
--- a/__tests__/helpers/navigation.helpers.test.ts
+++ b/__tests__/helpers/navigation.helpers.test.ts
@@ -1,4 +1,4 @@
-import { mainSegment, sameMainPath } from '@/helpers/navigation.helpers';
+import { mainSegment, sameMainPath, getWaveRoute, getWaveHomeRoute } from '@/helpers/navigation.helpers';
describe('navigation.helpers', () => {
describe('mainSegment', () => {
@@ -25,4 +25,103 @@ describe('navigation.helpers', () => {
expect(sameMainPath('/one', '/two')).toBe(false);
});
});
+
+ describe('getWaveRoute', () => {
+ it('returns wave route with waveId', () => {
+ const result = getWaveRoute({
+ waveId: 'wave-123',
+ isDirectMessage: false,
+ isApp: false,
+ });
+ expect(result).toBe('/waves?wave=wave-123');
+ });
+
+ it('returns messages route when isDirectMessage is true', () => {
+ const result = getWaveRoute({
+ waveId: 'dm-456',
+ isDirectMessage: true,
+ isApp: false,
+ });
+ expect(result).toBe('/messages?wave=dm-456');
+ });
+
+ it('includes serialNo when provided as number', () => {
+ const result = getWaveRoute({
+ waveId: 'wave-123',
+ serialNo: 42,
+ isDirectMessage: false,
+ isApp: false,
+ });
+ expect(result).toBe('/waves?wave=wave-123&serialNo=42');
+ });
+
+ it('includes serialNo when provided as string', () => {
+ const result = getWaveRoute({
+ waveId: 'wave-123',
+ serialNo: '99',
+ isDirectMessage: false,
+ isApp: false,
+ });
+ expect(result).toBe('/waves?wave=wave-123&serialNo=99');
+ });
+
+ it('does not include serialNo when undefined', () => {
+ const result = getWaveRoute({
+ waveId: 'wave-123',
+ serialNo: undefined,
+ isDirectMessage: false,
+ isApp: false,
+ });
+ expect(result).toBe('/waves?wave=wave-123');
+ });
+
+ it('includes extraParams before wave and serialNo', () => {
+ const result = getWaveRoute({
+ waveId: 'wave-123',
+ serialNo: 10,
+ extraParams: { foo: 'bar', baz: 'qux' },
+ isDirectMessage: false,
+ isApp: false,
+ });
+ expect(result).toBe('/waves?foo=bar&baz=qux&wave=wave-123&serialNo=10');
+ });
+
+ it('ignores undefined extraParams values', () => {
+ const result = getWaveRoute({
+ waveId: 'wave-123',
+ extraParams: { keep: 'this', remove: undefined },
+ isDirectMessage: false,
+ isApp: false,
+ });
+ expect(result).toBe('/waves?keep=this&wave=wave-123');
+ });
+
+ it('encodes special characters in query params', () => {
+ const result = getWaveRoute({
+ waveId: 'wave&id=special',
+ serialNo: 5,
+ isDirectMessage: false,
+ isApp: false,
+ });
+ expect(result).toBe('/waves?wave=wave%26id%3Dspecial&serialNo=5');
+ });
+ });
+
+ describe('getWaveHomeRoute', () => {
+ it('returns /waves for non-direct messages', () => {
+ const result = getWaveHomeRoute({
+ isDirectMessage: false,
+ isApp: false,
+ });
+ expect(result).toBe('/waves');
+ });
+
+ it('returns /messages for direct messages', () => {
+ const result = getWaveHomeRoute({
+ isDirectMessage: true,
+ isApp: false,
+ });
+ expect(result).toBe('/messages');
+ });
+ });
});
diff --git a/__tests__/useWaveRealtimeUpdater.test.ts b/__tests__/useWaveRealtimeUpdater.test.ts
index 426c5ae14c..b209f88a6b 100644
--- a/__tests__/useWaveRealtimeUpdater.test.ts
+++ b/__tests__/useWaveRealtimeUpdater.test.ts
@@ -40,6 +40,7 @@ describe("useWaveRealtimeUpdater", () => {
.mockResolvedValue({ drops: null, highestSerialNo: null }),
removeDrop: jest.fn(),
removeWaveDeliveredNotifications: jest.fn().mockResolvedValue(undefined),
+ isWaveMuted: jest.fn().mockReturnValue(false),
});
it("optimistically adds drop and syncs newest messages", async () => {
@@ -230,4 +231,27 @@ describe("useWaveRealtimeUpdater", () => {
expect(props.removeWaveDeliveredNotifications).not.toHaveBeenCalled();
expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled();
});
+
+ it("skips processing when wave is muted", async () => {
+ const store = {
+ wave1: { drops: [], latestFetchedSerialNo: 10 },
+ };
+ const props = baseProps(store);
+ props.isWaveMuted = jest.fn().mockReturnValue(true);
+ const { result } = renderHook(() => useWaveRealtimeUpdater(props));
+ const drop: any = { id: "d11", wave: { id: "wave1" }, author: {} };
+
+ await act(async () =>
+ result.current.processIncomingDrop(
+ drop,
+ ProcessIncomingDropType.DROP_INSERT
+ )
+ );
+ await flushPromises();
+
+ expect(props.isWaveMuted).toHaveBeenCalledWith("wave1");
+ expect(props.updateData).not.toHaveBeenCalled();
+ expect(props.registerWave).not.toHaveBeenCalled();
+ expect(props.syncNewestMessages).not.toHaveBeenCalled();
+ });
});
diff --git a/__tests__/utils/mockFactories.ts b/__tests__/utils/mockFactories.ts
index 3f8b89fb94..812cfaa604 100644
--- a/__tests__/utils/mockFactories.ts
+++ b/__tests__/utils/mockFactories.ts
@@ -14,10 +14,15 @@ export function createMockMinimalWave(overrides: Partial
= {}): Min
newDropsCount: {
count: 0,
latestDropTimestamp: null,
+ firstUnreadSerialNo: null,
},
picture: null,
contributors: [],
isPinned: false,
+ unreadDropsCount: 0,
+ latestReadTimestamp: 0,
+ firstUnreadDropSerialNo: null,
+ isMuted: false,
...overrides,
};
}
diff --git a/components/brain/left-sidebar/waves/BrainLeftSidebarWave.tsx b/components/brain/left-sidebar/waves/BrainLeftSidebarWave.tsx
index 9299fe9c91..1994da7dc8 100644
--- a/components/brain/left-sidebar/waves/BrainLeftSidebarWave.tsx
+++ b/components/brain/left-sidebar/waves/BrainLeftSidebarWave.tsx
@@ -15,6 +15,8 @@ import {
getWaveHomeRoute,
getWaveRoute,
} from "../../../../helpers/navigation.helpers";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faBellSlash } from "@fortawesome/free-solid-svg-icons";
interface BrainLeftSidebarWaveProps {
readonly wave: MinimalWave;
@@ -60,10 +62,16 @@ const BrainLeftSidebarWave: React.FC = ({
if (activeWaveId === wave.id) {
return getWaveHomeRoute({ isDirectMessage, isApp });
}
- return getWaveRoute({ waveId: wave.id, isDirectMessage, isApp });
- }, [activeWaveId, isApp, isDirectMessage, wave.id]);
+ return getWaveRoute({
+ waveId: wave.id,
+ serialNo: wave.firstUnreadDropSerialNo ?? undefined,
+ isDirectMessage,
+ isApp,
+ });
+ }, [activeWaveId, isApp, isDirectMessage, wave.id, wave.firstUnreadDropSerialNo]);
- const haveNewDrops = wave.newDropsCount.count > 0;
+ const unreadCount = Math.max(wave.unreadDropsCount, wave.newDropsCount.count);
+ const haveNewDrops = unreadCount > 0;
const onWaveHover = useCallback(() => {
if (wave.id !== activeWaveId) {
@@ -83,9 +91,12 @@ const BrainLeftSidebarWave: React.FC = ({
event.preventDefault();
onWaveHover();
const nextWaveId = wave.id === activeWaveId ? null : wave.id;
- activeWave.set(nextWaveId, { isDirectMessage });
+ activeWave.set(nextWaveId, {
+ isDirectMessage,
+ serialNo: nextWaveId ? wave.firstUnreadDropSerialNo : undefined,
+ });
},
- [activeWave.set, activeWaveId, isDirectMessage, onWaveHover, wave.id]
+ [activeWave.set, activeWaveId, isDirectMessage, onWaveHover, wave.id, wave.firstUnreadDropSerialNo]
);
const getAvatarRingClasses = () => {
@@ -133,9 +144,17 @@ const BrainLeftSidebarWave: React.FC = ({
)}
- {!isActive && haveNewDrops && (
+ {!isActive && haveNewDrops && !wave.isMuted && (
- {wave.newDropsCount.count}
+ {unreadCount > 99 ? "99+" : unreadCount}
+
+ )}
+ {wave.isMuted && (
+