Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion src/components/notifications/NotificationBell.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, afterEach, beforeEach, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { NotificationBell } from "./NotificationBell";
import { useNotificationStore } from "@/stores/notificationStore";
import { useUIStore } from "@/stores/uiStore";
Expand Down Expand Up @@ -67,3 +67,71 @@ test("an id no longer in the inbox still leaves the popover open", () => {
expect(screen.getByText("notifications.bell.clearHistory")).toBeTruthy();
expect(useUIStore.getState().notificationFocusId).toBeNull();
});

// The mobile shell swaps the foreground tab's bell while the SFTP tab's bell
// stays mounted behind `invisible`.
function TwoBells({ hostsTab }: { hostsTab: boolean }) {
return (
<>
{hostsTab && <div data-testid="hosts"><NotificationBell /></div>}
<div data-testid="sftp"><NotificationBell /></div>
</>
);
}

function stubHiddenSftpBell(hidden: () => boolean) {
const real = window.getComputedStyle;
window.getComputedStyle = ((el: Element) =>
el instanceof HTMLButtonElement && el.closest("[data-testid='sftp']") && hidden()
? ({ visibility: "hidden" } as CSSStyleDeclaration)
: real(el)) as typeof window.getComputedStyle;
return () => {
window.getComputedStyle = real;
};
}

const popovers = () => screen.queryAllByText("notifications.bell.clearHistory").length;

test("only the visible bell paints while both are mounted", () => {
const restore = stubHiddenSftpBell(() => true);
try {
render(<TwoBells hostsTab />);
act(() => useUIStore.getState().openNotificationCenter(null));
expect(popovers()).toBe(1);
} finally {
restore();
}
});

test("the popover follows the bell a tab switch makes visible", () => {
let sftpHidden = true;
const restore = stubHiddenSftpBell(() => sftpHidden);
try {
const { rerender } = render(<TwoBells hostsTab />);
act(() => useUIStore.getState().openNotificationCenter(null));
expect(popovers()).toBe(1);

sftpHidden = false;
rerender(<TwoBells hostsTab={false} />);

expect(popovers()).toBe(1);
fireEvent.click(screen.getAllByRole("button")[0]);
expect(useUIStore.getState().notificationCenterOpen).toBe(false);
} finally {
restore();
}
});

test("a link raised with no visible bell paints once one mounts", () => {
const restore = stubHiddenSftpBell(() => true);
try {
const { rerender } = render(<TwoBells hostsTab={false} />);
act(() => useUIStore.getState().openNotificationCenter(null));
expect(popovers()).toBe(0);

rerender(<TwoBells hostsTab />);
expect(popovers()).toBe(1);
} finally {
restore();
}
});
30 changes: 28 additions & 2 deletions src/components/notifications/NotificationBell.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n";
Expand All @@ -21,6 +21,26 @@ const SEVERITY_COLORS: Record<string, string> = {
error: "var(--t-status-error)",
};

// Which bell is on screen changes when bells mount and unmount: the mobile
// shell swaps the foreground tab's bell while the SFTP tab's stays mounted
// behind `invisible`. Bells re-measure on this signal so the popover follows
// the bell the user can actually see instead of staying with the one that was
// visible when it opened.
let mountVersion = 0;
const mountListeners = new Set<() => void>();

function bumpMounts() {
mountVersion += 1;
for (const listener of mountListeners) listener();
}

function subscribeMounts(listener: () => void) {
mountListeners.add(listener);
return () => {
mountListeners.delete(listener);
};
}

function relativeTime(ms: number): string {
const diff = Date.now() - ms;
if (diff < 60_000) return i18n.t("notifications.bell.relativeTime.justNow");
Expand Down Expand Up @@ -165,6 +185,12 @@ export function NotificationBell() {
const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);

const mounts = useSyncExternalStore(subscribeMounts, () => mountVersion);
useEffect(() => {
bumpMounts();
return bumpMounts;
}, []);

// Placement is measured on every open, not on click: a deep link opens the
// popover with no pointer event to measure from.
//
Expand All @@ -182,7 +208,7 @@ export function NotificationBell() {
}
const rect = button.getBoundingClientRect();
setPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
}, [open]);
}, [open, mounts]);

// A stale id is normal — inbox entries are re-derived, not stored — so a miss
// leaves the popover open on the full list rather than reporting anything.
Expand Down