();
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ };
+});
+
+vi.mock("@/services/api/notifications/notificationsAPI", () => ({
+ fetchFollowStatus: vi.fn(),
+ markNotificationRead: vi.fn(),
+}));
+
+vi.mock("@/stores/auth.store", () => ({
+ useAuthStore: () => ({ user: { id: "me" } }),
+}));
+
+vi.mock("@/stores/notification.store", () => ({
+ useNotificationStore: () => ({ markOneAsRead: mockMarkOneAsRead }),
+}));
+
+vi.mock("@/components/UI/FollowButton", () => ({
+ default: ({ username, userId, initialIsFollowing }: any) => (
+
+ ),
+}));
+
+vi.mock("@/components/UI/UserAvatar", () => ({
+ default: ({ src, name, alt, imageDataTest, fallbackDataTest }: any) => (
+
),
-}))
-
-vi.mock('@/services/api/notifications/notificationsAPI', () => ({
- markNotificationRead: vi.fn().mockResolvedValue({ success: true }),
-}))
-
-// Modal mock exposes onClose via a data-test button so tests can trigger it directly
-vi.mock('@/components/UI/Modal', () => ({
- Modal: ({
- isOpen,
- onClose,
- children,
- }: {
- isOpen: boolean
- onClose: () => void
- children: React.ReactNode
- }) =>
+}));
+
+vi.mock("@/components/UI/Modal", () => ({
+ Modal: ({ isOpen, onClose, children }: any) => (
isOpen ? (
-
+
{children}
- ) : null,
-}))
-
-vi.mock('@/components/UI/BlockModal', () => ({
- BlockUserModal: ({ username, onClose, onBlocked }: any) => (
-
- {username}
-
-
-
+ ) : null
),
-}))
-
-vi.mock('@/components/UI/ReportModal', () => ({
- ReportModal: ({ username, onClose, onSpamSelected }: any) => (
-
-
{username}
-
-
+}));
+
+vi.mock("@/components/UI/BlockModal", () => ({
+ BlockUserModal: ({ userId, username, onClose, onBlocked }: any) => (
+
+
+
),
-}))
-
-// ─── Factories ────────────────────────────────────────────────────────────────
+}));
-const NOW = new Date().toISOString()
+vi.mock("@/components/UI/ReportModal", () => ({
+ ReportModal: ({ userId, username, onClose, onSpamSelected }: any) => (
+
+
+
+
+ ),
+}));
-// NotificationActor uses `avatar`, not `profile_picture`
-const defaultActor = {
- id: 'user-1',
- username: 'johndoe',
- display_name: 'John Doe',
- avatar: null as string | null,
+vi.mock("@/components/UI/SpamModal", () => ({
+ SpamModal: ({ userId, username, onClose }: any) => (
+
+
+
+ ),
+}));
+
+import NotificationCard from "@/components/notificationsComponents/notificationCard";
+import { fetchFollowStatus } from "@/services/api/notifications/notificationsAPI";
+
+const mockFetchFollowStatus = fetchFollowStatus as ReturnType
;
+
+function notification(overrides: Partial = {}): Notification {
+ return {
+ id: "n1",
+ type: "follow",
+ actor: {
+ id: "u1",
+ username: "alice",
+ display_name: "Alice",
+ avatar: "avatar.png",
+ },
+ resource_type: "track",
+ resource_id: "track-1",
+ resource_details: { title: "Blue Song", content: "Nice!" },
+ is_read: false,
+ created_at: new Date(Date.now() - 5 * 60_000).toISOString(),
+ ...overrides,
+ };
}
-// Helper to build a typed NotificationResourceDetails
-const makeResource = (overrides: Partial = {}): NotificationResourceDetails => ({
- title: undefined,
- content: undefined,
- ...overrides,
-})
-
-// Notification uses resource_type, resource_id, resource_details — not `resource`
-const makeNotification = (overrides: Partial = {}): Notification => ({
- id: 'notif-1',
- type: 'follow',
- is_read: false,
- created_at: NOW,
- actor: defaultActor,
- resource_type: null,
- resource_id: null,
- resource_details: null,
- ...overrides,
-})
-
-const renderCard = (notification: Notification, showActions = true) =>
- render(
+function renderCard(n = notification(), props: Partial> = {}) {
+ return render(
-
+
,
- )
-
-// ─── Tests ────────────────────────────────────────────────────────────────────
+ );
+}
-describe('NotificationCard', () => {
+describe("NotificationCard", () => {
beforeEach(() => {
- mockNavigate.mockReset()
- vi.useFakeTimers()
- vi.setSystemTime(new Date('2024-01-01T12:00:00Z'))
- })
+ vi.clearAllMocks();
+ vi.spyOn(Date, "now").mockReturnValue(new Date("2026-05-01T12:00:00.000Z").getTime());
+ mockFetchFollowStatus.mockResolvedValue({
+ data: {
+ is_following: true,
+ is_followed_by: false,
+ is_blocking: false,
+ is_blocked_by: false,
+ },
+ });
+ mockMarkOneAsRead.mockResolvedValue({});
+ });
afterEach(() => {
- vi.useRealTimers()
- })
-
- // ── Static rendering ──────────────────────────────────────────────────────
-
- describe('rendering', () => {
- it('renders the notification card root', () => {
- renderCard(makeNotification())
- expect(screen.getByTestId('notification-card-notif-1')).toBeInTheDocument()
- })
-
- it('renders the actor display name', () => {
- renderCard(makeNotification())
- expect(screen.getByTestId('notification-username-notif-1')).toHaveTextContent('John Doe')
- })
-
- it('renders the time element', () => {
- renderCard(makeNotification())
- expect(screen.getByTestId('notification-time-notif-1')).toBeInTheDocument()
- })
-
- it('renders avatar wrapper', () => {
- renderCard(makeNotification())
- expect(screen.getByTestId('notification-avatar-notif-1')).toBeInTheDocument()
- })
- })
-
- // ── Avatar ────────────────────────────────────────────────────────────────
-
- describe('avatar', () => {
- it('renders avatar image when avatar is provided', () => {
- const n = makeNotification({
- actor: { ...defaultActor, avatar: 'https://example.com/pic.jpg' },
- })
- renderCard(n)
- expect(screen.getByTestId('notification-avatar-img-notif-1')).toHaveAttribute(
- 'src',
- 'https://example.com/pic.jpg',
- )
- })
-
- it('renders fallback icon when avatar is null', () => {
- renderCard(makeNotification())
- expect(screen.getByTestId('notification-avatar-fallback-notif-1')).toBeInTheDocument()
- })
-
- it('shows unread dot overlay when is_read is false', () => {
- renderCard(makeNotification({ is_read: false }))
- const avatar = screen.getByTestId('notification-avatar-notif-1')
- expect(avatar.querySelector('.bg-red-500')).toBeInTheDocument()
- })
-
- it('does NOT show unread dot when is_read is true', () => {
- renderCard(makeNotification({ is_read: true }))
- const avatar = screen.getByTestId('notification-avatar-notif-1')
- expect(avatar.querySelector('.bg-red-500')).not.toBeInTheDocument()
- })
- })
-
- // ── Action text ───────────────────────────────────────────────────────────
-
- describe('buildActionText', () => {
- it('renders "started following you" for follow type', () => {
- renderCard(makeNotification({ type: 'follow' }))
- expect(screen.getByTestId('notification-action-text-notif-1')).toHaveTextContent(
- 'started following you',
- )
- })
-
- it('renders liked text with track title for like type', () => {
- renderCard(
- makeNotification({ type: 'like', resource_details: makeResource({ title: 'My Song' }) }),
- )
- expect(screen.getByTestId('notification-action-text-notif-1')).toHaveTextContent(
- 'liked your track "My Song"',
- )
- })
-
- it('falls back to resource id when title is undefined for like type', () => {
- renderCard(
- makeNotification({ type: 'like', resource_id: 'track-42', resource_details: makeResource({ title: undefined }) }),
- )
- expect(screen.getByTestId('notification-action-text-notif-1')).toHaveTextContent(
- 'liked your track "track-42"',
- )
- })
-
- it('renders reposted text with track title for repost type', () => {
- renderCard(
- makeNotification({ type: 'repost', resource_details: makeResource({ title: 'My Song' }) }),
- )
- expect(screen.getByTestId('notification-action-text-notif-1')).toHaveTextContent(
- 'reposted your track "My Song"',
- )
- })
-
- it('falls back to resource id when title is undefined for repost type', () => {
- renderCard(
- makeNotification({ type: 'repost', resource_id: 'track-7', resource_details: makeResource({ title: undefined }) }),
- )
- expect(screen.getByTestId('notification-action-text-notif-1')).toHaveTextContent(
- 'reposted your track "track-7"',
- )
- })
-
- it('renders comment text with body for comment type', () => {
- renderCard(
- makeNotification({ type: 'comment', resource_details: makeResource({ content: 'Great track!' }) }),
- )
- expect(screen.getByTestId('notification-action-text-notif-1')).toHaveTextContent(
- 'commented "Great track!" on your track',
- )
- })
-
- // ── ?? '' fallback branches ───────────────────────────────────────────
-
- it('falls back to empty string when resource_details is null for like type', () => {
- renderCard(makeNotification({ type: 'like', resource_details: null }))
- expect(screen.getByTestId('notification-action-text-notif-1')).toHaveTextContent(
- 'liked your track ""',
- )
- })
-
- it('falls back to empty string when resource_details is null for repost type', () => {
- renderCard(makeNotification({ type: 'repost', resource_details: null }))
- expect(screen.getByTestId('notification-action-text-notif-1')).toHaveTextContent(
- 'reposted your track ""',
- )
- })
-
- it('falls back to empty string when content is undefined for comment type', () => {
- renderCard(
- makeNotification({ type: 'comment', resource_details: makeResource({ content: undefined }) }),
- )
- expect(screen.getByTestId('notification-action-text-notif-1')).toHaveTextContent(
- 'commented "" on your track',
- )
- })
-
- it('falls back to empty string when resource_details is null for comment type', () => {
- renderCard(makeNotification({ type: 'comment', resource_details: null }))
- expect(screen.getByTestId('notification-action-text-notif-1')).toHaveTextContent(
- 'commented "" on your track',
- )
- })
-
- it('renders empty string for unknown type', () => {
- renderCard(makeNotification({ type: 'unknown' as any }))
- expect(screen.getByTestId('notification-action-text-notif-1')).toHaveTextContent('')
- })
- })
-
- // ── Relative time ─────────────────────────────────────────────────────────
-
- describe('formatRelativeTime', () => {
- it('shows minutes ago for recent notifications', () => {
- const createdAt = new Date('2024-01-01T11:45:00Z').toISOString()
- renderCard(makeNotification({ created_at: createdAt }))
- expect(screen.getByTestId('notification-time-notif-1')).toHaveTextContent('15 minutes ago')
- })
-
- it('shows singular hour for exactly 1 hour ago', () => {
- const createdAt = new Date('2024-01-01T11:00:00Z').toISOString()
- renderCard(makeNotification({ created_at: createdAt }))
- expect(screen.getByTestId('notification-time-notif-1')).toHaveTextContent('1 hour ago')
- })
-
- it('shows plural hours for 2+ hours ago', () => {
- const createdAt = new Date('2024-01-01T10:00:00Z').toISOString()
- renderCard(makeNotification({ created_at: createdAt }))
- expect(screen.getByTestId('notification-time-notif-1')).toHaveTextContent('2 hours ago')
- })
-
- it('shows singular day for exactly 1 day ago', () => {
- const createdAt = new Date('2023-12-31T12:00:00Z').toISOString()
- renderCard(makeNotification({ created_at: createdAt }))
- expect(screen.getByTestId('notification-time-notif-1')).toHaveTextContent('1 day ago')
- })
-
- it('shows plural days for 2+ days ago', () => {
- const createdAt = new Date('2023-12-30T12:00:00Z').toISOString()
- renderCard(makeNotification({ created_at: createdAt }))
- expect(screen.getByTestId('notification-time-notif-1')).toHaveTextContent('2 days ago')
- })
- })
-
- // ── Navigation ────────────────────────────────────────────────────────────
-
- describe('navigation', () => {
- beforeEach(() => {
- vi.useRealTimers()
- })
- it('navigates to user profile on click for follow notifications', async () => {
- renderCard(makeNotification({ type: 'follow' }))
- fireEvent.click(screen.getByTestId('notification-card-notif-1'))
- await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/johndoe'))
- })
-
- it('navigates to track page on click for like notifications', async () => {
- renderCard(makeNotification({ type: 'like', resource_id: 'track-99' }))
- fireEvent.click(screen.getByTestId('notification-card-notif-1'))
- await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/tracks/track-99'))
- })
-
- it('navigates to track page on click for comment notifications', async () => {
- renderCard(makeNotification({ type: 'comment', resource_id: 'track-5' }))
- fireEvent.click(screen.getByTestId('notification-card-notif-1'))
- await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/tracks/track-5'))
- })
-
- it('navigates to track page on click for repost notifications', async () => {
- renderCard(makeNotification({ type: 'repost', resource_id: 'track-8', resource_details: makeResource({ title: 'Remix' }) }))
- fireEvent.click(screen.getByTestId('notification-card-notif-1'))
- await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/tracks/track-8'))
- })
- })
-
- // ── Follow button ─────────────────────────────────────────────────────────
-
- describe('FollowButton', () => {
- it('renders FollowButton for follow type', () => {
- renderCard(makeNotification({ type: 'follow' }))
- expect(screen.getByTestId('follow-btn-johndoe')).toBeInTheDocument()
- })
-
- it('does NOT render FollowButton for like type', () => {
- renderCard(makeNotification({ type: 'like', resource_details: makeResource({ title: 'S' }) }))
- expect(screen.queryByTestId('follow-btn-johndoe')).not.toBeInTheDocument()
- })
- })
-
- // ── 3-dots menu ───────────────────────────────────────────────────────────
-
- describe('3-dots dropdown menu', () => {
- it('renders the 3-dots menu button when showActions is true', () => {
- renderCard(makeNotification())
- expect(screen.getByTestId('notification-menu-btn-notif-1')).toBeInTheDocument()
- })
-
- it('does NOT render the 3-dots button when showActions is false', () => {
- renderCard(makeNotification(), false)
- expect(screen.queryByTestId('notification-menu-btn-notif-1')).not.toBeInTheDocument()
- })
-
- it('menu is hidden by default', () => {
- renderCard(makeNotification())
- expect(screen.queryByTestId('notification-menu-notif-1')).not.toBeInTheDocument()
- })
-
- it('opens the dropdown when 3-dots button is clicked', () => {
- renderCard(makeNotification())
- fireEvent.click(screen.getByTestId('notification-menu-btn-notif-1'))
- expect(screen.getByTestId('notification-menu-notif-1')).toBeInTheDocument()
- })
-
- it('toggles the dropdown closed on second click', () => {
- renderCard(makeNotification())
- const btn = screen.getByTestId('notification-menu-btn-notif-1')
- fireEvent.click(btn)
- fireEvent.click(btn)
- expect(screen.queryByTestId('notification-menu-notif-1')).not.toBeInTheDocument()
- })
-
- it('shows block and report buttons in the dropdown', () => {
- renderCard(makeNotification())
- fireEvent.click(screen.getByTestId('notification-menu-btn-notif-1'))
- expect(screen.getByTestId('notification-block-btn-notif-1')).toBeInTheDocument()
- expect(screen.getByTestId('notification-report-btn-notif-1')).toBeInTheDocument()
- })
-
- it('clicking the actions area does NOT propagate to the card row (no navigation)', () => {
- renderCard(makeNotification({ type: 'follow' }))
- fireEvent.click(screen.getByTestId('notification-actions-notif-1'))
- expect(mockNavigate).not.toHaveBeenCalled()
- })
- })
-
- // ── Block modal ───────────────────────────────────────────────────────────
-
- describe('Block modal', () => {
- it('opens block modal when block button is clicked', () => {
- renderCard(makeNotification())
- fireEvent.click(screen.getByTestId('notification-menu-btn-notif-1'))
- fireEvent.click(screen.getByTestId('notification-block-btn-notif-1'))
- expect(screen.getByTestId('block-modal')).toBeInTheDocument()
- })
-
- it('closes menu when block button is clicked', () => {
- renderCard(makeNotification())
- fireEvent.click(screen.getByTestId('notification-menu-btn-notif-1'))
- fireEvent.click(screen.getByTestId('notification-block-btn-notif-1'))
- expect(screen.queryByTestId('notification-menu-notif-1')).not.toBeInTheDocument()
- })
-
- it('closes block modal via onClose callback', () => {
- renderCard(makeNotification())
- fireEvent.click(screen.getByTestId('notification-menu-btn-notif-1'))
- fireEvent.click(screen.getByTestId('notification-block-btn-notif-1'))
- fireEvent.click(screen.getByText('close'))
- expect(screen.queryByTestId('block-modal')).not.toBeInTheDocument()
- })
-
- it('closes block modal via onBlocked callback', () => {
- renderCard(makeNotification())
- fireEvent.click(screen.getByTestId('notification-menu-btn-notif-1'))
- fireEvent.click(screen.getByTestId('notification-block-btn-notif-1'))
- fireEvent.click(screen.getByText('blocked'))
- expect(screen.queryByTestId('block-modal')).not.toBeInTheDocument()
- })
-
- // ── Covers line 169: Modal onClose={() => setIsBlockOpen(false)} ──────
- it('closes block modal via Modal backdrop onClose (covers line 169)', () => {
- renderCard(makeNotification())
- fireEvent.click(screen.getByTestId('notification-menu-btn-notif-1'))
- fireEvent.click(screen.getByTestId('notification-block-btn-notif-1'))
- expect(screen.getByTestId('block-modal')).toBeInTheDocument()
- // Trigger the onClose prop passed to directly (the backdrop close)
- fireEvent.click(screen.getByTestId('modal-backdrop-close'))
- expect(screen.queryByTestId('block-modal')).not.toBeInTheDocument()
- })
- })
-
- // ── Report modal ──────────────────────────────────────────────────────────
-
- describe('Report modal', () => {
- it('opens report modal when report button is clicked', () => {
- renderCard(makeNotification())
- fireEvent.click(screen.getByTestId('notification-menu-btn-notif-1'))
- fireEvent.click(screen.getByTestId('notification-report-btn-notif-1'))
- expect(screen.getByTestId('report-modal')).toBeInTheDocument()
- })
-
- it('closes menu when report button is clicked', () => {
- renderCard(makeNotification())
- fireEvent.click(screen.getByTestId('notification-menu-btn-notif-1'))
- fireEvent.click(screen.getByTestId('notification-report-btn-notif-1'))
- expect(screen.queryByTestId('notification-menu-notif-1')).not.toBeInTheDocument()
- })
-
- it('closes report modal via onClose callback', () => {
- renderCard(makeNotification())
- fireEvent.click(screen.getByTestId('notification-menu-btn-notif-1'))
- fireEvent.click(screen.getByTestId('notification-report-btn-notif-1'))
- fireEvent.click(screen.getByText('close'))
- expect(screen.queryByTestId('report-modal')).not.toBeInTheDocument()
- })
-
- it('closes report modal via onSpamSelected callback', () => {
- renderCard(makeNotification())
- fireEvent.click(screen.getByTestId('notification-menu-btn-notif-1'))
- fireEvent.click(screen.getByTestId('notification-report-btn-notif-1'))
- fireEvent.click(screen.getByText('spam'))
- expect(screen.queryByTestId('report-modal')).not.toBeInTheDocument()
- })
-
- // ── Covers line 179: Modal onClose={() => setIsReportOpen(false)} ─────
- it('closes report modal via Modal backdrop onClose (covers line 179)', () => {
- renderCard(makeNotification())
- fireEvent.click(screen.getByTestId('notification-menu-btn-notif-1'))
- fireEvent.click(screen.getByTestId('notification-report-btn-notif-1'))
- expect(screen.getByTestId('report-modal')).toBeInTheDocument()
- // Trigger the onClose prop passed to directly (the backdrop close)
- fireEvent.click(screen.getByTestId('modal-backdrop-close'))
- expect(screen.queryByTestId('report-modal')).not.toBeInTheDocument()
- })
- })
-})
\ No newline at end of file
+ vi.restoreAllMocks();
+ });
+
+ it("renders actor avatar, name, unread marker, action text, and relative time", async () => {
+ renderCard(notification({ created_at: new Date(Date.now() - 5 * 60_000).toISOString() }));
+
+ expect(screen.getByTestId("notification-card-n1")).toBeInTheDocument();
+ expect(screen.getByTestId("notification-username-n1")).toHaveTextContent("Alice");
+ expect(screen.getByTestId("notification-action-text-n1")).toHaveTextContent("started following you");
+ expect(screen.getByTestId("notification-time-n1")).toHaveTextContent("5 minutes ago");
+ expect(screen.getByTestId("user-avatar")).toHaveAttribute("data-src", "avatar.png");
+ expect(await screen.findByTestId("follow-button")).toHaveAttribute("data-following", "true");
+ });
+
+ it.each([
+ ["like", "liked your track \"Blue Song\""],
+ ["repost", "reposted your track \"Blue Song\""],
+ ["comment", "commented \"Nice!\" on your track"],
+ ["new_post_by_followed", "posted a new track"],
+ ] as Array<[Notification["type"], string]>)("renders %s notification text", (type, text) => {
+ renderCard(notification({ type }));
+ expect(screen.getByTestId("notification-action-text-n1")).toHaveTextContent(text);
+ });
+
+ it.each([
+ [30_000, "just now"],
+ [2 * 60 * 60_000, "2 hours ago"],
+ [2 * 24 * 60 * 60_000, "2 days ago"],
+ ])("formats a %s ms old notification as %s", (age, text) => {
+ renderCard(notification({ created_at: new Date(Date.now() - age).toISOString() }));
+ expect(screen.getByTestId("notification-time-n1")).toHaveTextContent(text);
+ });
+
+ it("marks unread notifications as read and navigates to a follower profile", async () => {
+ const onMarkRead = vi.fn();
+ const user = userEvent.setup();
+ renderCard(notification(), { onMarkRead });
+
+ await user.click(screen.getByTestId("notification-card-n1"));
+
+ expect(mockMarkOneAsRead).toHaveBeenCalledWith("n1");
+ expect(onMarkRead).toHaveBeenCalledWith("n1");
+ expect(mockNavigate).toHaveBeenCalledWith("/alice");
+ });
+
+ it("still navigates if marking as read fails", async () => {
+ const user = userEvent.setup();
+ mockMarkOneAsRead.mockRejectedValueOnce(new Error("nope"));
+ renderCard(notification({ type: "like", resource_type: "track", resource_id: "t99" }));
+
+ await user.click(screen.getByTestId("notification-card-n1"));
+
+ expect(mockNavigate).toHaveBeenCalledWith("/track/t99");
+ });
+
+ it("navigates playlist notifications to the actor sets page and skips read calls when already read", async () => {
+ const user = userEvent.setup();
+ renderCard(notification({ is_read: true, type: "like", resource_type: "playlist", resource_id: "p1" }));
+
+ await user.click(screen.getByTestId("notification-card-n1"));
+
+ expect(mockMarkOneAsRead).not.toHaveBeenCalled();
+ expect(mockNavigate).toHaveBeenCalledWith("/alice/sets/p1");
+ });
+
+ it("opens block, report, spam, and close flows from the menu", async () => {
+ const user = userEvent.setup();
+ renderCard(notification());
+
+ await user.click(screen.getByTestId("notification-menu-btn-n1"));
+ await waitFor(() => expect(screen.getByTestId("notification-block-btn-n1")).not.toBeDisabled());
+ await user.click(screen.getByTestId("notification-block-btn-n1"));
+ expect(screen.getByTestId("block-modal")).toBeInTheDocument();
+
+ await user.click(screen.getByTestId("block-confirm"));
+ expect(screen.queryByTestId("block-modal")).not.toBeInTheDocument();
+
+ await user.click(screen.getByTestId("notification-menu-btn-n1"));
+ await user.click(screen.getByTestId("notification-report-btn-n1"));
+ expect(screen.getByTestId("report-modal")).toBeInTheDocument();
+
+ await user.click(screen.getByTestId("report-spam"));
+ expect(screen.queryByTestId("report-modal")).not.toBeInTheDocument();
+ expect(screen.getByTestId("spam-modal")).toBeInTheDocument();
+ });
+
+ it("closes each modal from its own close callback", async () => {
+ const user = userEvent.setup();
+ renderCard(notification());
+
+ await user.click(screen.getByTestId("notification-menu-btn-n1"));
+ await waitFor(() => expect(screen.getByTestId("notification-block-btn-n1")).not.toBeDisabled());
+ await user.click(screen.getByTestId("notification-block-btn-n1"));
+ expect(screen.getByTestId("block-modal")).toBeInTheDocument();
+ await user.click(screen.getByTestId("block-close"));
+ expect(screen.queryByTestId("block-modal")).not.toBeInTheDocument();
+
+ await user.click(screen.getByTestId("notification-menu-btn-n1"));
+ await user.click(screen.getByTestId("notification-report-btn-n1"));
+ expect(screen.getByTestId("report-modal")).toBeInTheDocument();
+ await user.click(screen.getByTestId("report-close"));
+ expect(screen.queryByTestId("report-modal")).not.toBeInTheDocument();
+
+ await user.click(screen.getByTestId("notification-menu-btn-n1"));
+ await user.click(screen.getByTestId("notification-report-btn-n1"));
+ await user.click(screen.getByTestId("report-spam"));
+ expect(screen.getByTestId("spam-modal")).toBeInTheDocument();
+ await user.click(screen.getByTestId("spam-close"));
+ expect(screen.queryByTestId("spam-modal")).not.toBeInTheDocument();
+ });
+
+ it("closes each modal from the shared modal onClose callback", async () => {
+ const user = userEvent.setup();
+ renderCard(notification());
+
+ await user.click(screen.getByTestId("notification-menu-btn-n1"));
+ await waitFor(() => expect(screen.getByTestId("notification-block-btn-n1")).not.toBeDisabled());
+ await user.click(screen.getByTestId("notification-block-btn-n1"));
+ await user.click(screen.getByTestId("modal-close"));
+ expect(screen.queryByTestId("block-modal")).not.toBeInTheDocument();
+
+ await user.click(screen.getByTestId("notification-menu-btn-n1"));
+ await user.click(screen.getByTestId("notification-report-btn-n1"));
+ await user.click(screen.getByTestId("modal-close"));
+ expect(screen.queryByTestId("report-modal")).not.toBeInTheDocument();
+
+ await user.click(screen.getByTestId("notification-menu-btn-n1"));
+ await user.click(screen.getByTestId("notification-report-btn-n1"));
+ await user.click(screen.getByTestId("report-spam"));
+ await user.click(screen.getByTestId("modal-close"));
+ expect(screen.queryByTestId("spam-modal")).not.toBeInTheDocument();
+ });
+
+ it("uses fallback resource text when title and content are missing", () => {
+ renderCard(notification({
+ type: "comment",
+ resource_type: null,
+ resource_id: "fallback-id",
+ resource_details: null,
+ }));
+
+ expect(screen.getByTestId("notification-action-text-n1")).toHaveTextContent('commented "" on your track');
+ });
+
+ it("renders empty action text for unknown notification types", () => {
+ renderCard(notification({ type: "mystery" as Notification["type"] }));
+ expect(screen.getByTestId("notification-action-text-n1")).toHaveTextContent("");
+ });
+
+ it("unblocks directly when the actor is already blocked", async () => {
+ const user = userEvent.setup();
+ mockFetchFollowStatus.mockResolvedValueOnce({
+ data: {
+ is_following: false,
+ is_followed_by: false,
+ is_blocking: true,
+ is_blocked_by: false,
+ },
+ });
+
+ renderCard(notification());
+
+ await user.click(screen.getByTestId("notification-menu-btn-n1"));
+ await waitFor(() => expect(screen.getByTestId("notification-block-btn-n1")).toHaveTextContent("Unblock Alice"));
+ await user.click(screen.getByTestId("notification-block-btn-n1"));
+
+ await user.click(screen.getByTestId("notification-menu-btn-n1"));
+ expect(screen.getByTestId("notification-block-btn-n1")).toHaveTextContent("Block Alice");
+ });
+
+ it("hides actions when showActions is false and handles follow-status failure", async () => {
+ mockFetchFollowStatus.mockRejectedValueOnce(new Error("nope"));
+ renderCard(notification(), { showActions: false });
+
+ expect(screen.queryByTestId("notification-menu-btn-n1")).not.toBeInTheDocument();
+ expect(await screen.findByTestId("follow-button")).toHaveAttribute("data-following", "false");
+ });
+
+ it("does not fetch follow status when the actor has no id", () => {
+ renderCard(notification({ actor: { id: "", username: "ghost", display_name: "Ghost", avatar: null } }));
+ expect(mockFetchFollowStatus).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/notificationsComponents/tests/notificationHeader.test.tsx b/src/components/notificationsComponents/tests/notificationHeader.test.tsx
index 0e7f1cb2..9a6ac82b 100644
--- a/src/components/notificationsComponents/tests/notificationHeader.test.tsx
+++ b/src/components/notificationsComponents/tests/notificationHeader.test.tsx
@@ -1,158 +1,63 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { render, screen, fireEvent } from '@testing-library/react'
-import NotificationHeader, { type FilterType } from '../notificationHeader'
-
-// ─── Helpers ──────────────────────────────────────────────────────────────────
-
-const renderHeader = (selectedType: FilterType = 'all', onTypeChange = vi.fn()) =>
- render()
-
-// ─── Tests ────────────────────────────────────────────────────────────────────
-
-describe('NotificationHeader', () => {
-
- // ── Static rendering ──────────────────────────────────────────────────────
-
- describe('rendering', () => {
- it('renders the page title', () => {
- renderHeader()
- expect(screen.getByTestId('notification-header-title')).toHaveTextContent('Notifications')
- })
-
- it('renders the filter dropdown button', () => {
- renderHeader()
- expect(screen.getByTestId('notification-filter-btn')).toBeInTheDocument()
- })
-
- it('renders data-test attribute on root', () => {
- renderHeader()
- expect(screen.getByTestId('notification-header')).toBeInTheDocument()
- })
-
- it('does NOT show the filter menu by default (closed state)', () => {
- renderHeader()
- expect(screen.queryByTestId('notification-filter-menu')).not.toBeInTheDocument()
- })
- })
-
- // ── Button label reflects selectedType ────────────────────────────────────
-
- describe('selected label', () => {
- const cases: [FilterType, string][] = [
- ['all', 'All notifications'],
- ['like', 'Likes'],
- ['comment', 'Comments'],
- ['repost', 'Reposts'],
- ['follow', 'Follows'],
- ]
-
- it.each(cases)('shows "%s" label when selectedType is "%s"', (type, label) => {
- renderHeader(type)
- expect(screen.getByTestId('notification-filter-btn')).toHaveTextContent(label)
- })
- })
-
- // ── Dropdown open / close ─────────────────────────────────────────────────
-
- describe('dropdown toggle', () => {
- it('opens the menu when the button is clicked', () => {
- renderHeader()
- fireEvent.click(screen.getByTestId('notification-filter-btn'))
- expect(screen.getByTestId('notification-filter-menu')).toBeInTheDocument()
- })
-
- it('closes the menu when the button is clicked again', () => {
- renderHeader()
- const btn = screen.getByTestId('notification-filter-btn')
- fireEvent.click(btn) // open
- fireEvent.click(btn) // close
- expect(screen.queryByTestId('notification-filter-menu')).not.toBeInTheDocument()
- })
-
- it('renders all 5 filter options when open', () => {
- renderHeader()
- fireEvent.click(screen.getByTestId('notification-filter-btn'))
-
- const options: FilterType[] = ['all', 'like', 'comment', 'repost', 'follow']
- options.forEach(value => {
- expect(screen.getByTestId(`notification-filter-option-${value}`)).toBeInTheDocument()
- })
- })
-
- it('renders correct labels for each option', () => {
- renderHeader()
- fireEvent.click(screen.getByTestId('notification-filter-btn'))
-
- const expected: [FilterType, string][] = [
- ['all', 'All notifications'],
- ['like', 'Likes'],
- ['comment', 'Comments'],
- ['repost', 'Reposts'],
- ['follow', 'Follows'],
- ]
- expected.forEach(([value, label]) => {
- expect(screen.getByTestId(`notification-filter-option-${value}`)).toHaveTextContent(label)
- })
- })
- })
-
- // ── Option selection ──────────────────────────────────────────────────────
-
- describe('option selection', () => {
- it('calls onTypeChange with the selected value', () => {
- const onTypeChange = vi.fn()
- renderHeader('all', onTypeChange)
- fireEvent.click(screen.getByTestId('notification-filter-btn'))
- fireEvent.click(screen.getByTestId('notification-filter-option-like'))
- expect(onTypeChange).toHaveBeenCalledWith('like')
- })
-
- it('calls onTypeChange exactly once per click', () => {
- const onTypeChange = vi.fn()
- renderHeader('all', onTypeChange)
- fireEvent.click(screen.getByTestId('notification-filter-btn'))
- fireEvent.click(screen.getByTestId('notification-filter-option-comment'))
- expect(onTypeChange).toHaveBeenCalledTimes(1)
- })
-
- it('closes the menu after selecting an option', () => {
- renderHeader()
- fireEvent.click(screen.getByTestId('notification-filter-btn'))
- fireEvent.click(screen.getByTestId('notification-filter-option-repost'))
- expect(screen.queryByTestId('notification-filter-menu')).not.toBeInTheDocument()
- })
-
- it('calls onTypeChange with "follow" when follow option is clicked', () => {
- const onTypeChange = vi.fn()
- renderHeader('all', onTypeChange)
- fireEvent.click(screen.getByTestId('notification-filter-btn'))
- fireEvent.click(screen.getByTestId('notification-filter-option-follow'))
- expect(onTypeChange).toHaveBeenCalledWith('follow')
- })
-
- it('calls onTypeChange with "all" when all option is clicked', () => {
- const onTypeChange = vi.fn()
- renderHeader('like', onTypeChange)
- fireEvent.click(screen.getByTestId('notification-filter-btn'))
- fireEvent.click(screen.getByTestId('notification-filter-option-all'))
- expect(onTypeChange).toHaveBeenCalledWith('all')
- })
- })
-
- // ── Active option styling ─────────────────────────────────────────────────
-
- describe('active option highlight', () => {
- it('applies orange text to the currently selected option', () => {
- renderHeader('like')
- fireEvent.click(screen.getByTestId('notification-filter-btn'))
- expect(screen.getByTestId('notification-filter-option-like')).toHaveClass('text-orange-500')
- })
-
- it('does NOT apply orange text to unselected options', () => {
- renderHeader('like')
- fireEvent.click(screen.getByTestId('notification-filter-btn'))
- expect(screen.getByTestId('notification-filter-option-all')).not.toHaveClass('text-orange-500')
- expect(screen.getByTestId('notification-filter-option-comment')).not.toHaveClass('text-orange-500')
- })
- })
-})
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import NotificationHeader, { type FilterType } from "@/components/notificationsComponents/notificationHeader";
+
+describe("NotificationHeader", () => {
+ const onTypeChange = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders the title and selected filter label", () => {
+ render();
+
+ expect(screen.getByTestId("notification-header")).toBeInTheDocument();
+ expect(screen.getByTestId("notification-header-title")).toHaveTextContent("Notifications");
+ expect(screen.getByTestId("notification-filter-btn")).toHaveTextContent("All notifications");
+ expect(screen.queryByTestId("notification-filter-menu")).not.toBeInTheDocument();
+ });
+
+ it.each([
+ ["like", "Likes"],
+ ["comment", "Comments"],
+ ["repost", "Reposts"],
+ ["follow", "Follows"],
+ ] as Array<[FilterType, string]>)("shows the %s selected label", (selectedType, label) => {
+ render();
+ expect(screen.getByTestId("notification-filter-btn")).toHaveTextContent(label);
+ });
+
+ it("opens and closes the dropdown from the filter button", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByTestId("notification-filter-btn"));
+ expect(screen.getByTestId("notification-filter-menu")).toBeInTheDocument();
+
+ await user.click(screen.getByTestId("notification-filter-btn"));
+ expect(screen.queryByTestId("notification-filter-menu")).not.toBeInTheDocument();
+ });
+
+ it("calls onTypeChange and closes the menu when an option is selected", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByTestId("notification-filter-btn"));
+ await user.click(screen.getByTestId("notification-filter-option-comment"));
+
+ expect(onTypeChange).toHaveBeenCalledWith("comment");
+ expect(screen.queryByTestId("notification-filter-menu")).not.toBeInTheDocument();
+ });
+
+ it("marks the selected option visually", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByTestId("notification-filter-btn"));
+
+ expect(screen.getByTestId("notification-filter-option-follow")).toHaveClass("text-orange-500");
+ expect(screen.getByTestId("notification-filter-option-like")).toHaveClass("text-white");
+ });
+});
diff --git a/src/pages/search/SearchPage.tsx b/src/pages/search/SearchPage.tsx
index a66a5ae4..70dceb67 100644
--- a/src/pages/search/SearchPage.tsx
+++ b/src/pages/search/SearchPage.tsx
@@ -163,7 +163,7 @@ function EverythingResults({ q }: { q: string }) {
observer.observe(sentinel);
return () => observer.disconnect();
- }, [fetchPage]);
+ }, [fetchPage, items.length]);
// Keep hasMoreRef in sync
useEffect(() => {
@@ -323,4 +323,4 @@ export default function SearchPage() {
);
-}
\ No newline at end of file
+}
diff --git a/src/pages/search/albums/AlbumsPage.tsx b/src/pages/search/albums/AlbumsPage.tsx
index f3281f4a..19b0a6a4 100644
--- a/src/pages/search/albums/AlbumsPage.tsx
+++ b/src/pages/search/albums/AlbumsPage.tsx
@@ -98,6 +98,10 @@ export default function AlbumsPage() {
hasMoreRef.current = albums.length < total;
}, [albums.length, total]);
+ useEffect(() => {
+ return () => setFilters(null);
+ }, []);
+
// ── Empty query ───────────────────────────────────────────────────────────
if (!q.trim()) {
return (
@@ -122,10 +126,6 @@ export default function AlbumsPage() {
);
}
- useEffect(() => {
- return () => setFilters(null);
-}, []);
-
// ── Render ────────────────────────────────────────────────────────────────
return (
@@ -168,4 +168,4 @@ export default function AlbumsPage() {
);
-}
\ No newline at end of file
+}
diff --git a/src/pages/search/people/PeoplePage.tsx b/src/pages/search/people/PeoplePage.tsx
index 537d9aa1..8c2f4232 100644
--- a/src/pages/search/people/PeoplePage.tsx
+++ b/src/pages/search/people/PeoplePage.tsx
@@ -122,6 +122,10 @@ export default function PeoplePage() {
hasMoreRef.current = users.length < total;
}, [users.length, total]);
+ useEffect(() => {
+ return () => setFilters(null);
+ }, []);
+
// ── Empty query ───────────────────────────────────────────────────────────
if (!q.trim()) {
return (
@@ -146,9 +150,6 @@ export default function PeoplePage() {
);
}
- useEffect(() => {
- return () => setFilters(null);
-}, []);
// ── Render ────────────────────────────────────────────────────────────────
return (
@@ -199,4 +200,4 @@ export default function PeoplePage() {
);
-}
\ No newline at end of file
+}
diff --git a/src/pages/search/sets/SetsPage.tsx b/src/pages/search/sets/SetsPage.tsx
index 4eeb6781..c64364e7 100644
--- a/src/pages/search/sets/SetsPage.tsx
+++ b/src/pages/search/sets/SetsPage.tsx
@@ -98,6 +98,10 @@ export default function SetsPage() {
hasMoreRef.current = playlists.length < total;
}, [playlists.length, total]);
+ useEffect(() => {
+ return () => setFilters(null);
+ }, []);
+
if (!q.trim()) {
return (
@@ -120,10 +124,6 @@ export default function SetsPage() {
);
}
- useEffect(() => {
- return () => setFilters(null);
-}, []);
-
return (
@@ -165,4 +165,4 @@ export default function SetsPage() {
);
-}
\ No newline at end of file
+}
diff --git a/src/pages/search/sounds/SoundsPage.tsx b/src/pages/search/sounds/SoundsPage.tsx
index 29b72a51..ec43c521 100644
--- a/src/pages/search/sounds/SoundsPage.tsx
+++ b/src/pages/search/sounds/SoundsPage.tsx
@@ -100,6 +100,10 @@ export default function SoundsPage() {
hasMoreRef.current = tracks.length < total;
}, [tracks.length, total]);
+ useEffect(() => {
+ return () => setFilters(null);
+ }, []);
+
// ── Empty query ───────────────────────────────────────────────────────────
if (!q.trim()) {
return (
@@ -124,10 +128,6 @@ export default function SoundsPage() {
);
}
- useEffect(() => {
- return () => setFilters(null);
-}, []);
-
// ── Render ────────────────────────────────────────────────────────────────
return (
@@ -177,4 +177,4 @@ export default function SoundsPage() {
);
-}
\ No newline at end of file
+}
diff --git a/src/pages/social/messages/ModalNewMessageBody.tsx b/src/pages/social/messages/ModalNewMessageBody.tsx
index 56ed3e7e..9cd4cbdb 100644
--- a/src/pages/social/messages/ModalNewMessageBody.tsx
+++ b/src/pages/social/messages/ModalNewMessageBody.tsx
@@ -32,6 +32,8 @@ const ModalNewMessageBody = ({
const [messageError, setMessageError] = useState
(null)
const handleSend = async () => {
+ if (isSending) return
+
let hasError = false
if (!selected) {
@@ -53,14 +55,16 @@ const ModalNewMessageBody = ({
try {
let conversation: Conversation | null = null
let firstMessage: Message | null = null
+ let responseConversationId: string | undefined
if (embeds.length === 0) {
const res = await startConversation({
recipient_id: selected!.id,
body: message.trim(),
})
- const typed = res as { data?: { conversation?: Conversation; message?: Message } }
+ const typed = res as { data?: { conversation?: Conversation; conversation_id?: string; message?: Message } }
conversation = typed.data?.conversation ?? null
+ responseConversationId = typed.data?.conversation_id
firstMessage = typed.data?.message ?? null
} else {
const firstRes = await startConversation({
@@ -68,11 +72,16 @@ const ModalNewMessageBody = ({
...(message.trim() ? { body: message.trim() } : {}),
resource: { type: embeds[0].type, id: embeds[0].id },
})
- const typed = firstRes as { data?: { conversation?: Conversation; message?: Message } }
+ const typed = firstRes as { data?: { conversation?: Conversation; conversation_id?: string; message?: Message } }
conversation = typed.data?.conversation ?? null
+ responseConversationId = typed.data?.conversation_id
firstMessage = typed.data?.message ?? null
- const conversationId = conversation?.id
+ const conversationId =
+ conversation?.id ??
+ typed.data?.conversation_id ??
+ firstMessage?.conversation_id ??
+ ((firstMessage as any)?.conversationId as string | undefined)
if (conversationId && embeds.length > 1) {
for (let i = 1; i < embeds.length; i++) {
await sendMessage(conversationId, {
@@ -82,20 +91,26 @@ const ModalNewMessageBody = ({
}
}
- setMessage('')
- setEmbeds([])
- setBoxKey((k) => k + 1)
+ const conversationId =
+ conversation?.id ??
+ responseConversationId ??
+ firstMessage?.conversation_id ??
+ ((firstMessage as any)?.conversationId as string | undefined)
- // If the API didn't return a conversation ID, close the modal and stay put
- if (!conversation?.id) {
- onClose()
+ if (!conversationId) {
return
}
+ setMessage('')
+ setEmbeds([])
+ setBoxKey((k) => k + 1)
+
// We have a valid conversation — notify parent and navigate
- onConversationCreated?.(conversation, firstMessage as Message)
+ if (conversation) {
+ onConversationCreated?.(conversation, firstMessage as Message)
+ }
onClose()
- navigate(`/messages/${conversation.id}`)
+ navigate(`/messages/${conversationId}`)
} catch (err: unknown) {
const axiosError = err as { response?: { status: number } }
@@ -145,6 +160,7 @@ const ModalNewMessageBody = ({
onValueChange={(val) => { setMessage(val); if (val.trim()) setMessageError(null) }}
onIsEmptyChange={(empty) => { if (empty) setMessageError(null) }}
onEmbedsResolved={setEmbeds}
+ onSubmit={handleSend}
hasError={!!messageError}
/>
@@ -167,4 +183,4 @@ const ModalNewMessageBody = ({
)
}
-export default ModalNewMessageBody
\ No newline at end of file
+export default ModalNewMessageBody
diff --git a/src/pages/social/messages/[messageId]/MessageIdPage.tsx b/src/pages/social/messages/[messageId]/MessageIdPage.tsx
index 470b0b91..4b9f7ee9 100644
--- a/src/pages/social/messages/[messageId]/MessageIdPage.tsx
+++ b/src/pages/social/messages/[messageId]/MessageIdPage.tsx
@@ -39,15 +39,13 @@ export default function MessageIdPage() {
const [showMobileChat, setShowMobileChat] = useState(false);
// ─── Conversation-list pagination ─────────────────────────────────────────
- const [convPage, setConvPage] = useState(1);
const [loadingMoreConvs, setLoadingMoreConvs] = useState(false);
+ const [hasMoreConvs, setHasMoreConvs] = useState(false);
// Refs so the IntersectionObserver callback never captures stale state
const hasMoreConvsRef = useRef(false);
const loadingMoreRef = useRef(false);
const convPageRef = useRef(1);
- const sentinelRef = useRef(null);
- const scrollContainerRef = useRef(null);
const { refreshUnreadCount } = useMessagingStore();
@@ -134,6 +132,12 @@ export default function MessageIdPage() {
}, [activeConvId, loadingMsgs, hasPrevPage, currentPage]);
// ─── Fetch a page of conversations and append ─────────────────────────────
+ const syncHasMoreConvs = useCallback((loadedCount: number, totalItems: number) => {
+ const hasMore = loadedCount < totalItems;
+ hasMoreConvsRef.current = hasMore;
+ setHasMoreConvs(hasMore);
+ }, []);
+
const fetchConvPage = useCallback((page: number) => {
if (loadingMoreRef.current || !hasMoreConvsRef.current) return;
@@ -144,41 +148,27 @@ export default function MessageIdPage() {
.then((res) => {
const { items, pagination } = res.data;
setConversations((prev) => {
- const next = [...prev, ...items];
+ const existingIds = new Set(prev.map((conv) => conv.id));
+ const uniqueItems = items.filter((conv) => !existingIds.has(conv.id));
+ const next = [...prev, ...uniqueItems];
// FIX: sync ref synchronously inside the updater so the
// IntersectionObserver always sees the correct value on its
// next intersection event — no async useEffect lag.
- hasMoreConvsRef.current = next.length < pagination.total_items;
+ syncHasMoreConvs(next.length, pagination.total_items ?? next.length);
return next;
});
convPageRef.current = page;
- setConvPage(page);
})
.catch(() => setError("Could not load more conversations."))
.finally(() => {
loadingMoreRef.current = false;
setLoadingMoreConvs(false);
});
- }, []);
+ }, [syncHasMoreConvs]);
// ─── IntersectionObserver ─────────────────────────────────────────────────
- useEffect(() => {
- const sentinel = sentinelRef.current;
- const scrollContainer = scrollContainerRef.current;
- if (!sentinel || !scrollContainer) return;
-
- const observer = new IntersectionObserver(
- (entries) => {
- const entry = entries[0];
- if (entry.isIntersecting && hasMoreConvsRef.current && !loadingMoreRef.current) {
- fetchConvPage(convPageRef.current + 1);
- }
- },
- { root: scrollContainer, rootMargin: "100px" },
- );
-
- observer.observe(sentinel);
- return () => observer.disconnect();
+ const loadMoreConversations = useCallback(() => {
+ fetchConvPage(convPageRef.current + 1);
}, [fetchConvPage]);
// ─── 1. Initial fetch ─────────────────────────────────────────────────────
@@ -188,11 +178,8 @@ export default function MessageIdPage() {
.then((res) => {
const { items, pagination } = res.data;
setConversations(items);
- // FIX: sync ref synchronously here too, before any observer
- // callback can fire and find it still false.
- hasMoreConvsRef.current = items.length < pagination.total_items;
+ syncHasMoreConvs(items.length, pagination.total_items ?? items.length);
convPageRef.current = 1;
- setConvPage(1);
if (items.length === 0) return;
@@ -204,7 +191,7 @@ export default function MessageIdPage() {
})
.catch(() => setError("Could not load conversations."))
.finally(() => setLoadingConvs(false));
- }, []);
+ }, [loadConversation, messageId, syncHasMoreConvs]);
// ─── 2. Socket room management ────────────────────────────────────────────
useEffect(() => {
@@ -406,16 +393,17 @@ export default function MessageIdPage() {
} flex-col w-full md:w-85 shrink-0 sticky top-0 h-[calc(100vh-64px)]`}
>
-
@@ -465,4 +453,4 @@ export default function MessageIdPage() {